diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ea231e626e..35bc3f449b 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -363,6 +363,28 @@ def test_image_attach_appends_local_image(monkeypatch): assert len(server._sessions["sid"]["attached_images"]) == 1 +def test_commands_catalog_surfaces_quick_commands(monkeypatch): + monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": { + "build": {"type": "exec", "command": "npm run build"}, + "git": {"type": "alias", "target": "/shell git"}, + "notes": {"type": "exec", "command": "cat NOTES.md", "description": "Open design notes"}, + }}) + + resp = server.handle_request({"id": "1", "method": "commands.catalog", "params": {}}) + + pairs = dict(resp["result"]["pairs"]) + assert "npm run build" in pairs["/build"] + assert pairs["/git"].startswith("alias →") + assert pairs["/notes"] == "Open design notes" + + user_cat = next(c for c in resp["result"]["categories"] if c["name"] == "User commands") + user_pairs = dict(user_cat["pairs"]) + assert set(user_pairs) == {"/build", "/git", "/notes"} + + assert resp["result"]["canon"]["/build"] == "/build" + assert resp["result"]["canon"]["/notes"] == "/notes" + + def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch): monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}}) monkeypatch.setattr( @@ -509,3 +531,18 @@ def test_session_steer_errors_when_agent_has_no_steer_method(): assert "error" in resp, resp assert resp["error"]["code"] == 4010 + +def test_session_info_includes_mcp_servers(monkeypatch): + fake_status = [ + {"name": "github", "transport": "http", "tools": 12, "connected": True}, + {"name": "filesystem", "transport": "stdio", "tools": 4, "connected": True}, + {"name": "broken", "transport": "stdio", "tools": 0, "connected": False}, + ] + fake_mod = types.ModuleType("tools.mcp_tool") + fake_mod.get_mcp_status = lambda: fake_status + monkeypatch.setitem(sys.modules, "tools.mcp_tool", fake_mod) + + info = server._session_info(types.SimpleNamespace(tools=[], model="")) + + assert info["mcp_servers"] == fake_status + diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ccb9f7260b..d86db00066 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -588,6 +588,11 @@ def _session_info(agent) -> dict: info["skills"] = get_available_skills() except Exception: pass + try: + from tools.mcp_tool import get_mcp_status + info["mcp_servers"] = get_mcp_status() + except Exception: + info["mcp_servers"] = [] try: from hermes_cli.banner import get_update_result from hermes_cli.config import recommended_update_command @@ -1994,8 +1999,35 @@ def _(rid, params: dict) -> dict: cat_order.append(cat) cat_map[cat].append([name, desc]) - skill_count = 0 warning = "" + try: + qcmds = _load_cfg().get("quick_commands", {}) or {} + if isinstance(qcmds, dict) and qcmds: + bucket = "User commands" + if bucket not in cat_map: + cat_map[bucket] = [] + cat_order.append(bucket) + for qname, qc in sorted(qcmds.items()): + if not isinstance(qc, dict): + continue + key = f"/{qname}" + canon[key.lower()] = key + qtype = qc.get("type", "") + if qtype == "exec": + default_desc = f"exec: {qc.get('command', '')}" + elif qtype == "alias": + default_desc = f"alias → {qc.get('target', '')}" + else: + default_desc = qtype or "quick command" + qdesc = str(qc.get("description") or default_desc) + qdesc = qdesc[:120] + ("…" if len(qdesc) > 120 else "") + all_pairs.append([key, qdesc]) + cat_map[bucket].append([key, qdesc]) + except Exception as e: + if not warning: + warning = f"quick_commands discovery unavailable: {e}" + + skill_count = 0 try: from agent.skill_commands import scan_skill_commands for k, info in sorted(scan_skill_commands().items()): diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index e546ce640e..f1f0c306bc 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -4,7 +4,7 @@ import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js' import { resetOverlayState } from '../app/overlayStore.js' import { turnController } from '../app/turnController.js' import { resetTurnState } from '../app/turnStore.js' -import { resetUiState } from '../app/uiStore.js' +import { patchUiState, resetUiState } from '../app/uiStore.js' import { estimateTokensRough } from '../lib/text.js' import type { Msg } from '../types.js' @@ -47,6 +47,7 @@ describe('createGatewayEventHandler', () => { resetUiState() resetTurnState() turnController.fullReset() + patchUiState({ showReasoning: true }) }) it('persists completed tool rows when message.complete lands immediately after tool.complete', () => { diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 53a10fd8e0..1f2f938a93 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -17,6 +17,64 @@ describe('createSlashHandler', () => { expect(getOverlayState().picker).toBe(true) }) + it('opens the skills hub locally for bare /skills', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/skills')).toBe(true) + expect(getOverlayState().skillsHub).toBe(true) + expect(ctx.gateway.rpc).not.toHaveBeenCalled() + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + }) + + it('routes /skills install to skills.manage without opening overlay', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/skills install foo')).toBe(true) + expect(getOverlayState().skillsHub).toBe(false) + expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', { + action: 'install', + query: 'foo' + }) + }) + + it('routes /skills inspect to skills.manage', () => { + const ctx = buildCtx() + + createSlashHandler(ctx)('/skills inspect my-skill') + expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', { + action: 'inspect', + query: 'my-skill' + }) + }) + + it('routes /skills search to skills.manage', () => { + const ctx = buildCtx() + + createSlashHandler(ctx)('/skills search vibe') + expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', { + action: 'search', + query: 'vibe' + }) + }) + + it('routes /skills browse [page] to skills.manage with a numeric page', () => { + const ctx = buildCtx() + + createSlashHandler(ctx)('/skills browse 3') + expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', { + action: 'browse', + page: 3 + }) + }) + + it('shows usage for an unknown /skills subcommand', () => { + const ctx = buildCtx() + + createSlashHandler(ctx)('/skills zzz') + expect(ctx.gateway.rpc).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills')) + }) + it('cycles details mode and persists it', async () => { const ctx = buildCtx() diff --git a/ui-tui/src/__tests__/reasoning.test.ts b/ui-tui/src/__tests__/reasoning.test.ts new file mode 100644 index 0000000000..c961ea7a0c --- /dev/null +++ b/ui-tui/src/__tests__/reasoning.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js' + +describe('splitReasoning', () => { + it('extracts and strips it from text', () => { + const { reasoning, text } = splitReasoning('plotting\n\nhere is the answer') + + expect(reasoning).toBe('plotting') + expect(text).toBe('here is the answer') + }) + + it('handles multiple tag shapes', () => { + const input = 'a b c body' + const { reasoning, text } = splitReasoning(input) + + expect(reasoning).toContain('a') + expect(reasoning).toContain('b') + expect(reasoning).toContain('c') + expect(text).toBe('body') + }) + + it('treats unclosed trailing … as reasoning', () => { + const { reasoning, text } = splitReasoning('answer start still deciding') + + expect(reasoning).toBe('still deciding') + expect(text).toBe('answer start') + }) + + it('returns empty reasoning and untouched text when no tags present', () => { + const { reasoning, text } = splitReasoning('plain body with no tags') + + expect(reasoning).toBe('') + expect(text).toBe('plain body with no tags') + }) + + it('preserves text when reasoning block is empty', () => { + const { reasoning, text } = splitReasoning('only body') + + expect(reasoning).toBe('') + expect(text).toBe('only body') + }) + + it('detects presence of any supported tag', () => { + expect(hasReasoningTag('pre x post')).toBe(true) + expect(hasReasoningTag('pre x')).toBe(true) + expect(hasReasoningTag('x')).toBe(true) + expect(hasReasoningTag('no tags at all')).toBe(false) + }) +}) diff --git a/ui-tui/src/__tests__/syntax.test.ts b/ui-tui/src/__tests__/syntax.test.ts new file mode 100644 index 0000000000..505988b2ab --- /dev/null +++ b/ui-tui/src/__tests__/syntax.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { highlightLine, isHighlightable } from '../lib/syntax.js' +import { DEFAULT_THEME } from '../theme.js' + +const t = DEFAULT_THEME + +describe('syntax highlighter', () => { + it('recognizes supported langs and aliases', () => { + expect(isHighlightable('ts')).toBe(true) + expect(isHighlightable('js')).toBe(true) + expect(isHighlightable('python')).toBe(true) + expect(isHighlightable('rs')).toBe(true) + expect(isHighlightable('bash')).toBe(true) + expect(isHighlightable('whatever')).toBe(false) + expect(isHighlightable('')).toBe(false) + }) + + it('paints a whole-line comment dim', () => { + const tokens = highlightLine('// hello', 'ts', t) + + expect(tokens).toEqual([[t.color.dim, '// hello']]) + }) + + it('paints keywords, strings, and numbers in a ts line', () => { + const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t) + const colors = tokens.map(tok => tok[0]) + + expect(colors).toContain(t.color.bronze) // const + expect(colors).toContain(t.color.amber) // 'hi' + expect(colors).toContain(t.color.cornsilk) // 42 + }) + + it('falls through unchanged for unknown langs', () => { + const tokens = highlightLine(`const x = 1`, 'zzz', t) + + expect(tokens).toEqual([['', 'const x = 1']]) + }) + + it('treats `#` as a python comment, not a selector', () => { + const tokens = highlightLine('# comment', 'py', t) + + expect(tokens).toEqual([[t.color.dim, '# comment']]) + }) +}) diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts new file mode 100644 index 0000000000..c14ecff3aa --- /dev/null +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { $uiState, resetUiState } from '../app/uiStore.js' +import { applyDisplay } from '../app/useConfigSync.js' + +describe('applyDisplay', () => { + beforeEach(() => { + resetUiState() + }) + + it('fans every display flag out to $uiState and the bell callback', () => { + const setBell = vi.fn() + + applyDisplay( + { + config: { + display: { + bell_on_complete: true, + details_mode: 'expanded', + inline_diffs: false, + show_cost: true, + show_reasoning: true, + streaming: false, + tui_compact: true, + tui_statusbar: false + } + } + }, + setBell + ) + + const s = $uiState.get() + expect(setBell).toHaveBeenCalledWith(true) + expect(s.compact).toBe(true) + expect(s.detailsMode).toBe('expanded') + expect(s.inlineDiffs).toBe(false) + expect(s.showCost).toBe(true) + expect(s.showReasoning).toBe(true) + expect(s.statusBar).toBe(false) + expect(s.streaming).toBe(false) + }) + + it('applies v1 parity defaults when display fields are missing', () => { + const setBell = vi.fn() + + applyDisplay({ config: { display: {} } }, setBell) + + const s = $uiState.get() + expect(setBell).toHaveBeenCalledWith(false) + expect(s.inlineDiffs).toBe(true) + expect(s.showCost).toBe(false) + expect(s.showReasoning).toBe(false) + expect(s.statusBar).toBe(true) + expect(s.streaming).toBe(true) + }) + + it('treats a null config like an empty display block', () => { + const setBell = vi.fn() + + applyDisplay(null, setBell) + + const s = $uiState.get() + expect(setBell).toHaveBeenCalledWith(false) + expect(s.inlineDiffs).toBe(true) + expect(s.streaming).toBe(true) + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index e728f8bbd0..699a3794de 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -266,7 +266,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'tool.complete': turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary) - if (ev.payload.inline_diff) { + if (ev.payload.inline_diff && getUiState().inlineDiffs) { sys(ev.payload.inline_diff) } diff --git a/ui-tui/src/app/inputSelectionStore.ts b/ui-tui/src/app/inputSelectionStore.ts new file mode 100644 index 0000000000..25b67c4283 --- /dev/null +++ b/ui-tui/src/app/inputSelectionStore.ts @@ -0,0 +1,14 @@ +import { atom } from 'nanostores' + +export interface InputSelection { + clear: () => void + end: number + start: number + value: string +} + +export const $inputSelection = atom(null) + +export const setInputSelection = (next: InputSelection | null) => $inputSelection.set(next) + +export const getInputSelection = () => $inputSelection.get() diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 998afe2a19..a23b206883 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -57,6 +57,7 @@ export interface OverlayState { pager: null | PagerState picker: boolean secret: null | SecretReq + skillsHub: boolean sudo: null | SudoReq } @@ -78,9 +79,13 @@ export interface UiState { compact: boolean detailsMode: DetailsMode info: null | SessionInfo + inlineDiffs: boolean + showCost: boolean + showReasoning: boolean sid: null | string status: string statusBar: boolean + streaming: boolean theme: Theme usage: Usage } @@ -335,5 +340,6 @@ export interface AppOverlaysProps { export interface PasteSnippet { label: string + path?: string text: string } diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index 4b24f0daab..a2ea400233 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -9,13 +9,16 @@ const buildOverlayState = (): OverlayState => ({ pager: null, picker: false, secret: null, + skillsHub: false, sudo: null }) export const $overlayState = atom(buildOverlayState()) -export const $isBlocked = computed($overlayState, ({ approval, clarify, modelPicker, pager, picker, secret, sudo }) => - Boolean(approval || clarify || modelPicker || pager || picker || secret || sudo) +export const $isBlocked = computed( + $overlayState, + ({ approval, clarify, modelPicker, pager, picker, secret, skillsHub, sudo }) => + Boolean(approval || clarify || modelPicker || pager || picker || secret || skillsHub || sudo) ) export const getOverlayState = () => $overlayState.get() diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index a151b2cdc8..dd5a9f58c8 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,7 +1,12 @@ import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' import { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js' -import type { ConfigGetValueResponse, ConfigSetResponse, SessionSteerResponse, SessionUndoResponse } from '../../../gatewayTypes.js' +import type { + ConfigGetValueResponse, + ConfigSetResponse, + SessionSteerResponse, + SessionUndoResponse +} from '../../../gatewayTypes.js' import { writeOsc52Clipboard } from '../../../lib/osc52.js' import type { DetailsMode, Msg, PanelSection } from '../../../types.js' import { patchOverlayState } from '../../overlayStore.js' @@ -259,19 +264,27 @@ export const coreCommands: SlashCommand[] = [ // message isn't lost — identical semantics to the gateway handler. if (!ctx.ui.busy || !ctx.sid) { ctx.composer.enqueue(payload) - ctx.transcript.sys(`no active turn — queued for next: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`) + ctx.transcript.sys( + `no active turn — queued for next: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"` + ) + return } - ctx.gateway.rpc('session.steer', { session_id: ctx.sid, text: payload }).then( - ctx.guarded(r => { - if (r?.status === 'queued') { - ctx.transcript.sys(`⏩ steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"`) - } else { - ctx.transcript.sys('steer rejected') - } - }) - ).catch(ctx.guardedErr) + ctx.gateway + .rpc('session.steer', { session_id: ctx.sid, text: payload }) + .then( + ctx.guarded(r => { + if (r?.status === 'queued') { + ctx.transcript.sys( + `⏩ steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"` + ) + } else { + ctx.transcript.sys('steer rejected') + } + }) + ) + .catch(ctx.guardedErr) } }, diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index 979e1f470a..26318b3fb0 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -1,7 +1,209 @@ import type { ToolsConfigureResponse } from '../../../gatewayTypes.js' +import type { PanelSection } from '../../../types.js' +import { patchOverlayState } from '../../overlayStore.js' import type { SlashCommand } from '../types.js' +interface SkillInfo { + category?: string + description?: string + name?: string + path?: string +} + +interface SkillsListResponse { + skills?: Record +} + +interface SkillsInspectResponse { + info?: SkillInfo +} + +interface SkillsSearchResponse { + results?: { description?: string; name: string }[] +} + +interface SkillsInstallResponse { + installed?: boolean + name?: string +} + +interface SkillsBrowseItem { + description?: string + name: string + source?: string + trust?: string +} + +interface SkillsBrowseResponse { + items?: SkillsBrowseItem[] + page?: number + total?: number + total_pages?: number +} + export const opsCommands: SlashCommand[] = [ + { + help: 'browse, inspect, install skills', + name: 'skills', + run: (arg, ctx) => { + const text = arg.trim() + + if (!text) { + return patchOverlayState({ skillsHub: true }) + } + + const [sub, ...rest] = text.split(/\s+/) + const query = rest.join(' ').trim() + const { rpc } = ctx.gateway + const { page, panel, sys } = ctx.transcript + + if (sub === 'list') { + rpc('skills.manage', { action: 'list' }) + .then( + ctx.guarded(r => { + const cats = Object.entries(r.skills ?? {}).sort() + + if (!cats.length) { + return sys('no skills available') + } + + panel( + 'Skills', + cats.map(([title, items]) => ({ items, title })) + ) + }) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'inspect') { + if (!query) { + return sys('usage: /skills inspect ') + } + + rpc('skills.manage', { action: 'inspect', query }) + .then( + ctx.guarded(r => { + const info = r.info ?? {} + + if (!info.name) { + return sys(`unknown skill: ${query}`) + } + + const rows: [string, string][] = [ + ['Name', String(info.name)], + ['Category', String(info.category ?? '')], + ['Path', String(info.path ?? '')] + ] + + const sections: PanelSection[] = [{ rows }] + + if (info.description) { + sections.push({ text: String(info.description) }) + } + + panel('Skill', sections) + }) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'search') { + if (!query) { + return sys('usage: /skills search ') + } + + rpc('skills.manage', { action: 'search', query }) + .then( + ctx.guarded(r => { + const results = r.results ?? [] + + if (!results.length) { + return sys(`no results for: ${query}`) + } + + panel(`Search: ${query}`, [{ rows: results.map(s => [s.name, s.description ?? '']) }]) + }) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'install') { + if (!query) { + return sys('usage: /skills install ') + } + + sys(`installing ${query}…`) + + rpc('skills.manage', { action: 'install', query }) + .then( + ctx.guarded(r => + sys(r.installed ? `installed ${r.name ?? query}` : 'install failed') + ) + ) + .catch(ctx.guardedErr) + + return + } + + if (sub === 'browse') { + const pageNum = query ? parseInt(query, 10) : 1 + + if (Number.isNaN(pageNum) || pageNum < 1) { + return sys('usage: /skills browse [page] (page must be a positive number)') + } + + sys('fetching community skills (scans 6 sources, may take ~15s)…') + + rpc('skills.manage', { action: 'browse', page: pageNum }) + .then( + ctx.guarded(r => { + const items = r.items ?? [] + + if (!items.length) { + return sys(`no skills on page ${pageNum}${r.total ? ` (total ${r.total})` : ''}`) + } + + const rows: [string, string][] = items.map(s => [ + s.trust ? `${s.name} · ${s.trust}` : s.name, + String(s.description ?? '').slice(0, 160) + ]) + + const footer: string[] = [] + + if (r.page && r.total_pages) { + footer.push(`page ${r.page} of ${r.total_pages}`) + } + + if (r.total) { + footer.push(`${r.total} skills total`) + } + + if (r.page && r.total_pages && r.page < r.total_pages) { + footer.push(`/skills browse ${r.page + 1} for more`) + } + + panel(`Browse Skills${pageNum > 1 ? ` — p${pageNum}` : ''}`, [ + { rows }, + ...(footer.length ? [{ text: footer.join(' · ') }] : []) + ]) + }) + ) + .catch(ctx.guardedErr) + + return + } + + sys('usage: /skills [list | inspect | install | search | browse [page]]') + } + }, + { help: 'enable or disable tools (client-side history reset on change)', name: 'tools', diff --git a/ui-tui/src/app/slash/commands/setup.ts b/ui-tui/src/app/slash/commands/setup.ts index c6d5cc8637..d9a948e541 100644 --- a/ui-tui/src/app/slash/commands/setup.ts +++ b/ui-tui/src/app/slash/commands/setup.ts @@ -6,9 +6,8 @@ import type { SlashCommand } from '../types.js' export const setupCommands: SlashCommand[] = [ { - aliases: ['provider'], - help: 'configure LLM provider and model (launches `hermes model`)', - name: 'model', + help: 'configure LLM provider + model (launches `hermes model`)', + name: 'provider', run: (_arg, ctx) => void runExternalSetup({ args: ['model'], diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 73d0571734..236324ffb9 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -1,5 +1,6 @@ import { REASONING_PULSE_MS, STREAM_BATCH_MS } from '../config/timing.js' import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js' +import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js' import { buildToolTrailLine, estimateTokensRough, @@ -11,7 +12,7 @@ import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.j import { resetOverlayState } from './overlayStore.js' import { patchTurnState, resetTurnState } from './turnStore.js' -import { patchUiState } from './uiStore.js' +import { getUiState, patchUiState } from './uiStore.js' const INTERRUPT_COOLDOWN_MS = 1500 const ACTIVITY_LIMIT = 8 @@ -121,18 +122,31 @@ class TurnController { } flushStreamingSegment() { - const text = this.bufRef.trimStart() + const raw = this.bufRef.trimStart() - if (!text) { + if (!raw) { return } - const tools = this.pendingSegmentTools + const split = hasReasoningTag(raw) ? splitReasoning(raw) : { reasoning: '', text: raw } + + if (split.reasoning && !this.reasoningText.trim()) { + this.reasoningText = split.reasoning + patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) }) + } + + const text = split.text this.streamTimer = clear(this.streamTimer) - this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }] + + if (text) { + const tools = this.pendingSegmentTools + + this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }] + this.pendingSegmentTools = [] + } + this.bufRef = '' - this.pendingSegmentTools = [] patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' }) } @@ -187,8 +201,11 @@ class TurnController { } recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) { - const finalText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart() - const savedReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() + const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart() + const split = splitReasoning(rawText) + const finalText = split.text + const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() + const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n') const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 const savedToolTokens = this.toolTokenAcc const tools = this.pendingSegmentTools @@ -226,10 +243,17 @@ class TurnController { } this.bufRef = rendered ?? this.bufRef + text - this.scheduleStreaming() + + if (getUiState().streaming) { + this.scheduleStreaming() + } } recordReasoningAvailable(text: string) { + if (!getUiState().showReasoning) { + return + } + const incoming = text.trim() if (!incoming || this.reasoningText.trim()) { @@ -242,6 +266,10 @@ class TurnController { } recordReasoningDelta(text: string) { + if (!getUiState().showReasoning) { + return + } + this.reasoningText += text this.scheduleReasoning() this.pulseReasoningStreaming() @@ -344,7 +372,9 @@ class TurnController { this.streamTimer = setTimeout(() => { this.streamTimer = null - patchTurnState({ streaming: this.bufRef.trimStart() }) + const raw = this.bufRef.trimStart() + const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw + patchTurnState({ streaming: visible }) }, STREAM_BATCH_MS) } diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index b7f5c20f4d..81089f1795 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -11,9 +11,13 @@ const buildUiState = (): UiState => ({ compact: false, detailsMode: 'collapsed', info: null, + inlineDiffs: true, + showCost: false, + showReasoning: false, sid: null, status: 'summoning hermes…', statusBar: true, + streaming: true, theme: DEFAULT_THEME, usage: ZERO }) diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 14a40412c9..bebda273d9 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -70,12 +70,25 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32)) + void gw + .request<{ path?: string }>('paste.collapse', { text: cleanedText }) + .then(r => { + const path = r?.path + + if (!path) { + return + } + + setPasteSnips(prev => prev.map(s => (s.label === label ? { ...s, path } : s))) + }) + .catch(() => {}) + return { cursor: cursor + insert.length, value: value.slice(0, cursor) + insert + value.slice(cursor) } }, - [onClipboardPaste] + [gw, onClipboardPaste] ) const openEditor = useCallback(() => { diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index fe3cec5737..8a3756342b 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -27,14 +27,18 @@ const quietRpc = async = Record>( } } -const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => { +export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => { const d = cfg?.config?.display ?? {} setBell(!!d.bell_on_complete) patchUiState({ compact: !!d.tui_compact, detailsMode: resolveDetailsMode(d), - statusBar: d.tui_statusbar !== false + inlineDiffs: d.inline_diffs !== false, + showCost: !!d.show_cost, + showReasoning: !!d.show_reasoning, + statusBar: d.tui_statusbar !== false, + streaming: d.streaming !== false }) } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 70000b73c8..b71a1dc392 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -8,6 +8,9 @@ import type { VoiceRecordResponse } from '../gatewayTypes.js' +import { writeOsc52Clipboard } from '../lib/osc52.js' + +import { getInputSelection } from './inputSelectionStore.js' import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' @@ -63,6 +66,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return patchOverlayState({ modelPicker: false }) } + if (overlay.skillsHub) { + return patchOverlayState({ skillsHub: false }) + } + if (overlay.picker) { return patchOverlayState({ picker: false }) } @@ -243,6 +250,15 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return copySelection() } + const inputSel = getInputSelection() + + if (inputSel && inputSel.end > inputSel.start) { + writeOsc52Clipboard(inputSel.value.slice(inputSel.start, inputSel.end)) + inputSel.clear() + + return + } + if (live.busy && live.sid) { return turnController.interruptTurn({ appendMessage: actions.appendMessage, diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index ed6f914c96..2f5f807dec 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -99,6 +99,7 @@ export function StatusRule({ usage, bgCount, sessionStartedAt, + showCost, voiceLabel, t }: StatusRuleProps) { @@ -136,6 +137,9 @@ export function StatusRule({ ) : null} {voiceLabel ? │ {voiceLabel} : null} {bgCount > 0 ? │ {bgCount} bg : null} + {showCost && typeof usage.cost_usd === 'number' ? ( + │ ${usage.cost_usd.toFixed(4)} + ) : null} @@ -285,6 +289,7 @@ interface StatusRuleProps { cwdLabel: string model: string sessionStartedAt?: number | null + showCost: boolean status: string statusColor: string t: Theme diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 26d8e4b0a9..f13adf1bbd 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -190,6 +190,7 @@ const ComposerPane = memo(function ComposerPane({ cwdLabel={status.cwdLabel} model={ui.info?.model?.split('/').pop() ?? ''} sessionStartedAt={status.sessionStartedAt} + showCost={ui.showCost} status={ui.status} statusColor={status.statusColor} t={ui.theme} diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 23187cf3f9..27db09024f 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -11,6 +11,7 @@ import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' import { ApprovalPrompt, ClarifyPrompt } from './prompts.js' import { SessionPicker } from './sessionPicker.js' +import { SkillsHub } from './skillsHub.js' export function PromptZone({ cols, @@ -82,7 +83,7 @@ export function FloatingOverlays({ const overlay = useStore($overlayState) const ui = useStore($uiState) - const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || completions.length + const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length if (!hasAny) { return null @@ -115,6 +116,12 @@ export function FloatingOverlays({ )} + {overlay.skillsHub && ( + + patchOverlayState({ skillsHub: false })} t={ui.theme} /> + + )} + {overlay.pager && ( diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index fc019ac86f..919c34b612 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -126,11 +126,36 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) { {section('Tools', info.tools, 8, 'more toolsets…')} {section('Skills', info.skills)} + + {info.mcp_servers && info.mcp_servers.length > 0 && ( + + + MCP Servers + + + {info.mcp_servers.map(s => ( + + {` ${s.name} `} + {`[${s.transport}]`} + : + {s.connected ? ( + + {s.tools} tool{s.tools === 1 ? '' : 's'} + + ) : ( + failed + )} + + ))} + + )} + {flat(info.tools).length} tools{' · '} {flat(info.skills).length} skills + {info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''} {' · '} /help for commands diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 4555c8505f..5e1063837b 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -1,6 +1,7 @@ import { Box, Link, Text } from '@hermes/ink' import { memo, type ReactNode, useMemo } from 'react' +import { highlightLine, isHighlightable } from '../lib/syntax.js' import type { Theme } from '../theme.js' const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/ @@ -39,13 +40,17 @@ const trimBareUrl = (value: string) => { } } -const renderAutolink = (key: number, t: Theme, raw: string) => ( - - - {raw.replace(/^mailto:/, '')} - - -) +const renderAutolink = (key: number, t: Theme, raw: string) => { + const url = raw.startsWith('mailto:') ? raw : raw.includes('@') && !raw.startsWith('http') ? `mailto:${raw}` : raw + + return ( + + + {raw.replace(/^mailto:/, '')} + + + ) +} const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2) @@ -286,11 +291,28 @@ function MdImpl({ compact, t, text }: MdProps) { start('code') const isDiff = lang === 'diff' + const highlighted = !isDiff && isHighlightable(lang) nodes.push( {lang && !isDiff && {'─ ' + lang}} {block.map((l, j) => { + if (highlighted) { + return ( + + {highlightLine(l, lang, t).map(([color, text], k) => + color ? ( + + {text} + + ) : ( + {text} + ) + )} + + ) + } + const add = isDiff && l.startsWith('+') const del = isDiff && l.startsWith('-') const hunk = isDiff && l.startsWith('@@') diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 9de6f2aa12..8d77a49e57 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -35,7 +35,9 @@ export const MessageLine = memo(function MessageLine({ return ( {hasAnsi(msg.text) ? ( - {msg.text} + + {msg.text} + ) : ( {preview} diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 98aba0789b..c7ced5b31d 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -8,6 +8,7 @@ import { TextInput } from './textInput.js' const OPTS = ['once', 'session', 'always', 'deny'] as const const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const +const CMD_PREVIEW_LINES = 10 export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) { const [sel, setSel] = useState(0) @@ -34,13 +35,30 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) { } }) + const rawLines = req.command.split('\n') + const shown = rawLines.slice(0, CMD_PREVIEW_LINES) + const overflow = rawLines.length - shown.length + return ( ⚠ approval required · {req.description} - {req.command} + + {shown.map((line, i) => ( + + {line || ' '} + + ))} + + {overflow > 0 ? ( + + … +{overflow} more line{overflow === 1 ? '' : 's'} (full text above) + + ) : null} + + {OPTS.map((o, i) => ( diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx new file mode 100644 index 0000000000..877bb0ef38 --- /dev/null +++ b/ui-tui/src/components/skillsHub.tsx @@ -0,0 +1,296 @@ +import { Box, Text, useInput } from '@hermes/ink' +import { useEffect, useState } from 'react' + +import type { GatewayClient } from '../gatewayClient.js' +import { rpcErrorMessage } from '../lib/rpc.js' +import type { Theme } from '../theme.js' + +const VISIBLE = 12 + +const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) + +const visibleItems = (items: string[], sel: number) => { + const off = pageOffset(items.length, sel) + + return { items: items.slice(off, off + VISIBLE), off } +} + +export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { + const [skillsByCat, setSkillsByCat] = useState>({}) + const [selectedCat, setSelectedCat] = useState('') + const [catIdx, setCatIdx] = useState(0) + const [skillIdx, setSkillIdx] = useState(0) + const [stage, setStage] = useState<'actions' | 'category' | 'skill'>('category') + const [info, setInfo] = useState(null) + const [installing, setInstalling] = useState(false) + const [err, setErr] = useState('') + const [loading, setLoading] = useState(true) + + useEffect(() => { + gw.request<{ skills?: Record }>('skills.manage', { action: 'list' }) + .then(r => { + setSkillsByCat(r?.skills ?? {}) + setErr('') + setLoading(false) + }) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) + setLoading(false) + }) + }, [gw]) + + const cats = Object.keys(skillsByCat).sort() + const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : [] + const skillName = skills[skillIdx] ?? '' + + const inspect = (name: string) => { + setInfo(null) + setErr('') + + gw.request<{ info?: SkillInfo }>('skills.manage', { action: 'inspect', query: name }) + .then(r => setInfo(r?.info ?? { name })) + .catch((e: unknown) => setErr(rpcErrorMessage(e))) + } + + const install = (name: string) => { + setInstalling(true) + setErr('') + + gw.request<{ installed?: boolean; name?: string }>('skills.manage', { action: 'install', query: name }) + .then(() => onClose()) + .catch((e: unknown) => setErr(rpcErrorMessage(e))) + .finally(() => setInstalling(false)) + } + + useInput((ch, key) => { + if (installing) { + return + } + + if (key.escape) { + if (stage === 'actions') { + setStage('skill') + setInfo(null) + setErr('') + + return + } + + if (stage === 'skill') { + setStage('category') + setSkillIdx(0) + + return + } + + onClose() + + return + } + + if (stage === 'actions') { + if (key.return) { + setStage('skill') + setInfo(null) + setErr('') + + return + } + + if (ch.toLowerCase() === 'x' && skillName) { + install(skillName) + + return + } + + if (ch.toLowerCase() === 'i' && skillName) { + inspect(skillName) + } + + return + } + + const count = stage === 'category' ? cats.length : skills.length + const sel = stage === 'category' ? catIdx : skillIdx + const setSel = stage === 'category' ? setCatIdx : setSkillIdx + + if (key.upArrow && sel > 0) { + setSel(v => v - 1) + + return + } + + if (key.downArrow && sel < count - 1) { + setSel(v => v + 1) + + return + } + + if (key.return) { + if (stage === 'category') { + const cat = cats[catIdx] + + if (!cat) { + return + } + + setSelectedCat(cat) + setSkillIdx(0) + setStage('skill') + + return + } + + const name = skills[skillIdx] + + if (name) { + setStage('actions') + inspect(name) + } + + return + } + + const n = ch === '0' ? 10 : parseInt(ch, 10) + + if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) { + const off = pageOffset(count, sel) + const next = off + n - 1 + + if (stage === 'category') { + const cat = cats[next] + + if (cat) { + setSelectedCat(cat) + setCatIdx(next) + setSkillIdx(0) + setStage('skill') + } + + return + } + + const name = skills[next] + + if (name) { + setSkillIdx(next) + setStage('actions') + inspect(name) + } + } + }) + + if (loading) { + return loading skills… + } + + if (err && stage === 'category') { + return ( + + error: {err} + Esc to cancel + + ) + } + + if (!cats.length) { + return ( + + no skills available + Esc to cancel + + ) + } + + if (stage === 'category') { + const rows = cats.map(c => `${c} · ${skillsByCat[c]?.length ?? 0} skills`) + const { items, off } = visibleItems(rows, catIdx) + + return ( + + + Skills Hub + + + select a category + {off > 0 && ↑ {off} more} + + {items.map((row, i) => { + const idx = off + i + + return ( + + {catIdx === idx ? '▸ ' : ' '} + {i + 1}. {row} + + ) + })} + + {off + VISIBLE < rows.length && ↓ {rows.length - off - VISIBLE} more} + ↑/↓ select · Enter open · 1-9,0 quick · Esc cancel + + ) + } + + if (stage === 'skill') { + const { items, off } = visibleItems(skills, skillIdx) + + return ( + + + {selectedCat} + + + {skills.length} skill(s) + {!skills.length ? no skills in this category : null} + {off > 0 && ↑ {off} more} + + {items.map((row, i) => { + const idx = off + i + + return ( + + {skillIdx === idx ? '▸ ' : ' '} + {i + 1}. {row} + + ) + })} + + {off + VISIBLE < skills.length && ↓ {skills.length - off - VISIBLE} more} + + {skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back' : 'Esc back'} + + + ) + } + + return ( + + + {info?.name ?? skillName} + + + {info?.category ?? selectedCat} + {info?.description ? {info.description} : null} + {info?.path ? path: {info.path} : null} + {!info && !err ? loading… : null} + {err ? error: {err} : null} + {installing ? installing… : null} + + i reinspect · x reinstall · Enter/Esc back + + ) +} + +interface SkillInfo { + category?: string + description?: string + name?: string + path?: string +} + +interface SkillsHubProps { + gw: GatewayClient + onClose: () => void + t: Theme +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index f2bbee63cf..dff8121b5e 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -2,6 +2,8 @@ import type { InputEvent, Key } from '@hermes/ink' import * as Ink from '@hermes/ink' import { useEffect, useMemo, useRef, useState } from 'react' +import { setInputSelection } from '../app/inputSelectionStore.js' + type InkExt = typeof Ink & { stringWidth: (s: string) => number useDeclaredCursor: (a: { line: number; column: number; active: boolean }) => (el: any) => void @@ -351,6 +353,28 @@ export function TextInput({ } }, [value]) + useEffect(() => { + if (!focus) { + return + } + + if (selected) { + setInputSelection({ + clear: () => { + selRef.current = null + setSel(null) + }, + end: selected.end, + start: selected.start, + value: vRef.current + }) + } else { + setInputSelection(null) + } + + return () => setInputSelection(null) + }, [focus, selected]) + useEffect( () => () => { if (pasteTimer.current) { @@ -464,7 +488,7 @@ export function TextInput({ (inp: string, k: Key, event: InputEvent) => { const eventRaw = event.keypress.raw - if (eventRaw === '\x1bv' || eventRaw === '\x1bV') { + if (eventRaw === '\x1bv' || eventRaw === '\x1bV' || eventRaw === '\x16') { return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index e17e0e7c71..6fa1ad92e5 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -54,6 +54,10 @@ export type CommandDispatchResponse = export interface ConfigDisplayConfig { bell_on_complete?: boolean details_mode?: string + inline_diffs?: boolean + show_cost?: boolean + show_reasoning?: boolean + streaming?: boolean thinking_mode?: string tui_compact?: boolean tui_statusbar?: boolean diff --git a/ui-tui/src/lib/reasoning.ts b/ui-tui/src/lib/reasoning.ts new file mode 100644 index 0000000000..eba63918c4 --- /dev/null +++ b/ui-tui/src/lib/reasoning.ts @@ -0,0 +1,50 @@ +const TAGS = ['think', 'reasoning', 'thinking', 'thought', 'REASONING_SCRATCHPAD'] as const + +export interface SplitReasoning { + reasoning: string + text: string +} + +export function splitReasoning(input: string): SplitReasoning { + let text = input + const reasoning: string[] = [] + + for (const tag of TAGS) { + const paired = new RegExp(`<${tag}>([\\s\\S]*?)\\s*`, 'gi') + text = text.replace(paired, (_m, inner: string) => { + const trimmed = inner.trim() + + if (trimmed) { + reasoning.push(trimmed) + } + + return '' + }) + + const unclosed = new RegExp(`<${tag}>([\\s\\S]*)$`, 'i') + text = text.replace(unclosed, (_m, inner: string) => { + const trimmed = inner.trim() + + if (trimmed) { + reasoning.push(trimmed) + } + + return '' + }) + } + + return { + reasoning: reasoning.join('\n\n').trim(), + text: text.trim() + } +} + +export const hasReasoningTag = (input: string) => { + for (const tag of TAGS) { + if (input.includes(`<${tag}>`)) { + return true + } + } + + return false +} diff --git a/ui-tui/src/lib/syntax.ts b/ui-tui/src/lib/syntax.ts new file mode 100644 index 0000000000..06173b63e9 --- /dev/null +++ b/ui-tui/src/lib/syntax.ts @@ -0,0 +1,117 @@ +import type { Theme } from '../theme.js' + +export type Token = [string, string] + +interface LangSpec { + comment: null | string + keywords: Set +} + +const KW = (s: string) => new Set(s.split(/\s+/).filter(Boolean)) + +const TS = KW(` + abstract as async await break case catch class const continue debugger default delete do else enum export extends + false finally for from function get if implements import in instanceof interface is let new null of package private + protected public readonly return set static super switch this throw true try type typeof undefined var void while + with yield +`) + +const PY = KW(` + False None True and as assert async await break class continue def del elif else except finally for from global if + import in is lambda nonlocal not or pass raise return try while with yield +`) + +const SH = KW(` + if then else elif fi for in do done while until case esac function return break continue local export readonly + declare typeset +`) + +const GO = KW(` + break case chan const continue default defer else fallthrough for func go goto if import interface map package range + return select struct switch type var nil true false +`) + +const RUST = KW(` + as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut + pub ref return self Self static struct super trait true type unsafe use where while yield +`) + +const SQL = KW(` + select from where and or not in is null as by group order limit offset insert into values update set delete create + table drop alter add column primary key foreign references join left right inner outer on +`) + +const LANGS: Record = { + go: { comment: '//', keywords: GO }, + json: { comment: null, keywords: KW('true false null') }, + py: { comment: '#', keywords: PY }, + rust: { comment: '//', keywords: RUST }, + sh: { comment: '#', keywords: SH }, + sql: { comment: '--', keywords: SQL }, + ts: { comment: '//', keywords: TS }, + yaml: { comment: '#', keywords: KW('true false null yes no on off') } +} + +const ALIAS: Record = { + bash: 'sh', + javascript: 'ts', + js: 'ts', + jsx: 'ts', + python: 'py', + rs: 'rust', + shell: 'sh', + tsx: 'ts', + typescript: 'ts', + yml: 'yaml', + zsh: 'sh' +} + +const resolve = (lang: string): LangSpec | null => LANGS[ALIAS[lang] ?? lang] ?? null + +export const isHighlightable = (lang: string): boolean => resolve(lang) !== null + +const TOKEN_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|\b\d+(?:\.\d+)?\b|[A-Za-z_$][\w$]*/g + +export function highlightLine(line: string, lang: string, t: Theme): Token[] { + const spec = resolve(lang) + + if (!spec) { + return [['', line]] + } + + if (spec.comment && line.trimStart().startsWith(spec.comment)) { + return [[t.color.dim, line]] + } + + const tokens: Token[] = [] + let last = 0 + + for (const m of line.matchAll(TOKEN_RE)) { + const start = m.index ?? 0 + + if (start > last) { + tokens.push(['', line.slice(last, start)]) + } + + const tok = m[0] + const ch = tok[0]! + + if (ch === '"' || ch === "'" || ch === '`') { + tokens.push([t.color.amber, tok]) + } else if (ch >= '0' && ch <= '9') { + tokens.push([t.color.cornsilk, tok]) + } else if (spec.keywords.has(tok)) { + tokens.push([t.color.bronze, tok]) + } else { + tokens.push(['', tok]) + } + + last = start + tok.length + } + + if (last < line.length) { + tokens.push(['', line.slice(last)]) + } + + return tokens +} diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index ab7d7efab9..98cc31203c 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -51,8 +51,16 @@ export type Role = 'assistant' | 'system' | 'tool' | 'user' export type DetailsMode = 'hidden' | 'collapsed' | 'expanded' export type ThinkingMode = 'collapsed' | 'truncated' | 'full' +export interface McpServerStatus { + connected: boolean + name: string + tools: number + transport: string +} + export interface SessionInfo { cwd?: string + mcp_servers?: McpServerStatus[] model: string release_date?: string skills: Record @@ -68,6 +76,7 @@ export interface Usage { context_max?: number context_percent?: number context_used?: number + cost_usd?: number input: number output: number total: number diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 6815e4211b..9f8987ad34 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -63,7 +63,11 @@ declare module '@hermes/ink' { export const Box: React.ComponentType export const AlternateScreen: React.ComponentType export const Ansi: React.ComponentType - export const Link: React.ComponentType<{ readonly url: string; readonly children?: React.ReactNode; readonly fallback?: React.ReactNode }> + export const Link: React.ComponentType<{ + readonly children?: React.ReactNode + readonly fallback?: React.ReactNode + readonly url: string + }> export const NoSelect: React.ComponentType export const ScrollBox: React.ComponentType export const Text: React.ComponentType