From f0638f35964ee28cff608a05614524065488c0b7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:11:53 -0500 Subject: [PATCH 01/23] fix(tui): split /model picker from /provider wizard to resolve registry collision --- ui-tui/src/app/slash/commands/setup.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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'], From 4e1ea79edc8fa6d1e4958e9df19fcca042efa566 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:11:57 -0500 Subject: [PATCH 02/23] feat(tui): accept raw Ctrl+V as clipboard image paste fallback --- ui-tui/src/components/textInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index f2bbee63cf..6503da4dbf 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -464,7 +464,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 }) } From 5152e1ad8646235e4b745cf3d1337417b13f5ef5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:16:37 -0500 Subject: [PATCH 03/23] feat(tui-gateway): surface config.quick_commands in commands.catalog --- tui_gateway/server.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a7dae9e5c6..fad674aeb7 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1987,8 +1987,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()): From a397b0fd4d5c95b6aef4eecbb13eabad3d7e659b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:16:39 -0500 Subject: [PATCH 04/23] test(tui-gateway): assert quick_commands appear in commands.catalog output --- tests/test_tui_gateway_server.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ea231e626e..d441e2b32d 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( From 586b2f208913e2d63f08e426ac0c2ac6b3bc3823 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:16:44 -0500 Subject: [PATCH 05/23] feat(tui): persist large pastes to ~/.hermes/pastes/ via paste.collapse --- ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/useComposerState.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 998afe2a19..ff2b1e5b5a 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -335,5 +335,6 @@ export interface AppOverlaysProps { export interface PasteSnippet { label: string + path?: string text: string } 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(() => { From 200c17433c0ce24a9332b857e64b6db3041a1f59 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:23:29 -0500 Subject: [PATCH 06/23] feat(tui): read display.streaming / show_reasoning / show_cost / inline_diffs from config Extends ConfigDisplayConfig and UiState so the four new display flags flow from `config.get {key:"full"}` into the nanostore. applyDisplay is exported to keep the fan-out testable without an Ink harness. Defaults mirror v1 parity: streaming + inline_diffs default true (opt-out via `=== false`), show_cost + show_reasoning default false (opt-in via plain truthy check). --- ui-tui/src/__tests__/useConfigSync.test.ts | 67 ++++++++++++++++++++++ ui-tui/src/app/interfaces.ts | 4 ++ ui-tui/src/app/uiStore.ts | 4 ++ ui-tui/src/app/useConfigSync.ts | 8 ++- ui-tui/src/gatewayTypes.ts | 4 ++ 5 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/__tests__/useConfigSync.test.ts 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/interfaces.ts b/ui-tui/src/app/interfaces.ts index ff2b1e5b5a..bf3d54c627 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -78,9 +78,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 } 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/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/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index c8d1c68552..fd5b6c1347 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -53,6 +53,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 From fd6ffc777fea792f368dd3e3e86a66e438adafd3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:26:03 -0500 Subject: [PATCH 07/23] feat(tui): honor display.* flags in turn renderer, status bar, and event handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - turnController gates scheduleStreaming / reasoning recorders on streaming + showReasoning so disabling them keeps the buffer silent until message.complete flushes - createGatewayEventHandler only surfaces inline_diff previews when inlineDiffs is on - StatusRule takes a showCost prop and renders `· $X.XXXX` with the same toFixed(4) formatting as /usage when usage.cost_usd is present - Usage grows cost_usd?: number to match the gateway payload - Existing handler tests flip showReasoning on in beforeEach so reasoning-flow assertions keep their meaning --- .../__tests__/createGatewayEventHandler.test.ts | 3 ++- ui-tui/src/app/createGatewayEventHandler.ts | 2 +- ui-tui/src/app/turnController.ts | 15 +++++++++++++-- ui-tui/src/components/appChrome.tsx | 5 +++++ ui-tui/src/components/appLayout.tsx | 1 + ui-tui/src/types.ts | 1 + 6 files changed, 23 insertions(+), 4 deletions(-) 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/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/turnController.ts b/ui-tui/src/app/turnController.ts index 73d0571734..de57b2dd05 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -11,7 +11,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 @@ -226,10 +226,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 +249,10 @@ class TurnController { } recordReasoningDelta(text: string) { + if (!getUiState().showReasoning) { + return + } + this.reasoningText += text this.scheduleReasoning() this.pulseReasoningStreaming() 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/types.ts b/ui-tui/src/types.ts index ab7d7efab9..32e99983ac 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -68,6 +68,7 @@ export interface Usage { context_max?: number context_percent?: number context_used?: number + cost_usd?: number input: number output: number total: number From 202b78ec684aee2a0bc5964bc2a58d2d20f8fbfc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:23:47 -0500 Subject: [PATCH 08/23] feat(tui-gateway): include per-MCP-server status in session.info payload --- tui_gateway/server.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index fad674aeb7..7d4c3fa3c1 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 From b82ec6419d8fe49bf0bef46b45d78276157b9838 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:23:47 -0500 Subject: [PATCH 09/23] test(tui-gateway): cover mcp_servers field in _session_info output --- tests/test_tui_gateway_server.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index d441e2b32d..35bc3f449b 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -531,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 + From 382132302917348e060063b7516c0cd616b07df6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:23:47 -0500 Subject: [PATCH 10/23] feat(tui): render per-MCP-server status block in SessionPanel --- ui-tui/src/components/branding.tsx | 25 +++++++++++++++++++++++++ ui-tui/src/types.ts | 8 ++++++++ 2 files changed, 33 insertions(+) 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/types.ts b/ui-tui/src/types.ts index 32e99983ac..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 From 6fbfae8f42297a71e170de6103af459bd0a81f27 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:26:24 -0500 Subject: [PATCH 11/23] feat(tui): add skillsHub overlay state wiring Extend OverlayState with a skillsHub flag, fold it into $isBlocked, and teach Ctrl+C to close the overlay so later PRs can render the component behind this slot. --- ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/overlayStore.ts | 7 +++++-- ui-tui/src/app/useInputHandlers.ts | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index bf3d54c627..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 } 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/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 70000b73c8..0279a203ca 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -63,6 +63,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 }) } From ef284e021ac73fcdac9a8392a10bb42f2018b74f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:27:48 -0500 Subject: [PATCH 12/23] feat(tui): add two-step SkillsHub overlay component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SkillsHub mirrors ModelPicker's category → item → actions flow with paginated 12-line lists, 1-9/0 quick-pick, Esc-back navigation, and lazy skills.manage inspect/install calls. Mount it from appOverlays when overlay.skillsHub is true. --- ui-tui/src/components/appOverlays.tsx | 9 +- ui-tui/src/components/skillsHub.tsx | 290 ++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 ui-tui/src/components/skillsHub.tsx 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/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx new file mode 100644 index 0000000000..03ed3d92f3 --- /dev/null +++ b/ui-tui/src/components/skillsHub.tsx @@ -0,0 +1,290 @@ +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 || ch.toLowerCase() === 'x') { + if (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} + + Enter install · i inspect · x install · Esc back + + ) +} + +interface SkillInfo { + category?: string + description?: string + name?: string + path?: string +} + +interface SkillsHubProps { + gw: GatewayClient + onClose: () => void + t: Theme +} From 949b8f5521a6fc98d472f58aa9be3dedaa90e1d3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:29:39 -0500 Subject: [PATCH 13/23] feat(tui): register /skills slash command to open Skills Hub Intercept bare /skills locally and flip overlay.skillsHub, so the overlay opens instantly without waiting on slash.exec. /skills still forwards to slash.exec and paginates any output. Tests cover both branches. --- .../src/__tests__/createSlashHandler.test.ts | 20 ++++++++++++++++ ui-tui/src/app/slash/commands/ops.ts | 24 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 9e1db99463..c54a659b94 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -17,6 +17,26 @@ 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('falls through /skills with args to slash.exec without opening overlay', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/skills install foo')).toBe(true) + expect(getOverlayState().skillsHub).toBe(false) + expect(ctx.gateway.rpc).toHaveBeenCalledWith('slash.exec', { + command: 'skills install foo', + session_id: null + }) + }) + it('cycles details mode and persists it', async () => { const ctx = buildCtx() diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index 979e1f470a..aa02fa6cbb 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -1,7 +1,29 @@ -import type { ToolsConfigureResponse } from '../../../gatewayTypes.js' +import type { SlashExecResponse, ToolsConfigureResponse } from '../../../gatewayTypes.js' +import { patchOverlayState } from '../../overlayStore.js' import type { SlashCommand } from '../types.js' export const opsCommands: SlashCommand[] = [ + { + help: 'browse, inspect, and install skills', + name: 'skills', + run: (arg, ctx) => { + if (!arg.trim()) { + return patchOverlayState({ skillsHub: true }) + } + + ctx.gateway + .rpc('slash.exec', { command: `skills ${arg}`, session_id: ctx.sid }) + .then( + ctx.guarded(r => { + if (r.output) { + ctx.transcript.page(r.output, 'Skills') + } + }) + ) + .catch(ctx.guardedErr) + } + }, + { help: 'enable or disable tools (client-side history reset on change)', name: 'tools', From 5e148ca3d03f70d13b2f97d45f57d8664c2f7d55 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:46:36 -0500 Subject: [PATCH 14/23] fix(tui): route /skills subcommands through skills.manage instead of curses slash.exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /skills install, inspect, search, browse, list now call the typed skills.manage RPC and render results via panel/page. Previously they fell through to slash.exec which invokes v1's curses code path — that hangs or crashes inside the Ink worker per the §2 parity-audit finding. Also drop Enter-as-install from the Skills Hub action stage since the Hub lists locally installed skills; primary action is inspect-and-close. x still triggers a manual reinstall for power users. --- .../src/__tests__/createSlashHandler.test.ts | 46 ++++- ui-tui/src/app/slash/commands/ops.ts | 158 ++++++++++++++++-- ui-tui/src/components/skillsHub.tsx | 16 +- 3 files changed, 198 insertions(+), 22 deletions(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index c54a659b94..67aa27f768 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -26,17 +26,55 @@ describe('createSlashHandler', () => { expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) - it('falls through /skills with args to slash.exec without opening overlay', () => { + 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('slash.exec', { - command: 'skills install foo', - session_id: null + 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/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index aa02fa6cbb..d941c5af41 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -1,26 +1,158 @@ -import type { SlashExecResponse, ToolsConfigureResponse } from '../../../gatewayTypes.js' +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 +} + export const opsCommands: SlashCommand[] = [ { - help: 'browse, inspect, and install skills', + help: 'browse, inspect, install skills', name: 'skills', run: (arg, ctx) => { - if (!arg.trim()) { + const text = arg.trim() + + if (!text) { return patchOverlayState({ skillsHub: true }) } - ctx.gateway - .rpc('slash.exec', { command: `skills ${arg}`, session_id: ctx.sid }) - .then( - ctx.guarded(r => { - if (r.output) { - ctx.transcript.page(r.output, 'Skills') - } - }) - ) - .catch(ctx.guardedErr) + 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 = parseInt(query, 10) || 1 + + rpc>('skills.manage', { action: 'browse', page: pageNum }) + .then( + ctx.guarded>(r => + page(JSON.stringify(r, null, 2).slice(0, 4000), `Browse Skills — p${pageNum}`) + ) + ) + .catch(ctx.guardedErr) + + return + } + + sys('usage: /skills [list | inspect | install | search | browse [page]]') } }, diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 03ed3d92f3..877bb0ef38 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -89,10 +89,16 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { } if (stage === 'actions') { - if (key.return || ch.toLowerCase() === 'x') { - if (skillName) { - install(skillName) - } + if (key.return) { + setStage('skill') + setInfo(null) + setErr('') + + return + } + + if (ch.toLowerCase() === 'x' && skillName) { + install(skillName) return } @@ -271,7 +277,7 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { {err ? error: {err} : null} {installing ? installing… : null} - Enter install · i inspect · x install · Esc back + i reinspect · x reinstall · Enter/Esc back ) } From f8becbfbeab87b35424bf4c636a3b192a2072e5d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:48:38 -0500 Subject: [PATCH 15/23] feat(tui): per-language syntax highlighting in markdown code fences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a minimal hand-rolled highlighter for ts/js/jsx/tsx, py, sh/bash, go, rust, json, yaml, sql. Recognizes whole-line comments, single/double/backtick strings, numbers, and per-language keyword sets. Unknown langs fall through to the current plain rendering; the existing diff-specific colorization is preserved. Closes the §8 "Markdown syntax highlighting is missing (only diff gets colored)" finding from the TUI v2 audit without pulling in a highlighter library. --- ui-tui/src/__tests__/syntax.test.ts | 45 +++++++++++ ui-tui/src/components/markdown.tsx | 18 +++++ ui-tui/src/lib/syntax.ts | 117 ++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 ui-tui/src/__tests__/syntax.test.ts create mode 100644 ui-tui/src/lib/syntax.ts 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/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 865ab85796..d43357b691 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -1,6 +1,7 @@ import { Box, 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,})(.*)$/ @@ -282,11 +283,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/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 +} From a7f4d756b7048e4ece779ad44737ef060716b1b1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 14:36:34 -0500 Subject: [PATCH 16/23] fix(tui): cap approval prompt command preview at 10 lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large inline scripts (e.g. Python code_execution bodies) rendered as a single unbounded block, pushing the Allow/Deny options below the visible viewport. Users had to scroll the terminal to vote. Preview now shows the first 10 lines with truncate-end wrap per line and a dim "… +N more lines" indicator. Full text remains in the transcript above. --- ui-tui/src/components/prompts.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 98aba0789b..bfc603c51c 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,28 @@ 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) => ( From 5c8b291607e23eb11d5df70f560490bdd0b3dd6e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 14:39:24 -0500 Subject: [PATCH 17/23] fix(tui): wrap markdown links in Link so Ghostty/iTerm/kitty get real OSC 8 hyperlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderLink was discarding the URL entirely — it rendered the label as amber underlined text and dropped the href. Result: Cmd+Click / Ctrl+Click did nothing in any terminal, including Ghostty. Now both markdown links `[label](url)` and bare `https://…` URLs are wrapped in @hermes/ink's Link component, which emits OSC 8 (\\x1b]8;;url\\x07label\\x1b]8;;\\x07) when supportsHyperlinks() returns true. ADDITIONAL_HYPERLINK_TERMINALS already includes ghostty, iTerm2, kitty, alacritty, Hyper. Autolinks that look like bare emails (foo@bar.com) now prepend mailto: in the href so they open the mail client correctly. Also adds a typed declaration for Link in hermes-ink.d.ts. --- ui-tui/src/components/markdown.tsx | 30 +++++++++++++++++++----------- ui-tui/src/types/hermes-ink.d.ts | 5 +++++ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index d43357b691..5e1063837b 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -1,4 +1,4 @@ -import { Box, Text } from '@hermes/ink' +import { Box, Link, Text } from '@hermes/ink' import { memo, type ReactNode, useMemo } from 'react' import { highlightLine, isHighlightable } from '../lib/syntax.js' @@ -23,10 +23,12 @@ type Fence = { len: number } -const renderLink = (key: number, t: Theme, label: string) => ( - - {label} - +const renderLink = (key: number, t: Theme, label: string, url: string) => ( + + + {label} + + ) const trimBareUrl = (value: string) => { @@ -38,11 +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) @@ -142,7 +150,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[4] && m[5]) { - parts.push(renderLink(parts.length, t, m[4])) + parts.push(renderLink(parts.length, t, m[4], m[5])) } else if (m[6]) { parts.push(renderAutolink(parts.length, t, m[6])) } else if (m[7]) { diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 9b2deec35f..051451d419 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -63,6 +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 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 From 37cba82bfcf84a64be145fcc7ee9d69a8867dc5d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 14:42:03 -0500 Subject: [PATCH 18/23] fix(tui): Ctrl+C on in-input selection copies to clipboard instead of clearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: textInput explicitly ignored Ctrl+C so the app-level handler took over — with no knowledge of the TextInput's own selection — and fell through to clearIn() whenever input had text. Selecting part of the composer and pressing Ctrl+C silently nuked everything you typed. Now: Ctrl+C with an active in-input selection writes the selected substring to the clipboard via OSC 52 and clears the selection. The original semantics (Ctrl+C with no selection → app-level interrupt/clear/die chain) are preserved by still returning early in that case. --- ui-tui/src/components/textInput.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 6503da4dbf..a0f7c42f3b 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 { writeOsc52Clipboard } from '../lib/osc52.js' + type InkExt = typeof Ink & { stringWidth: (s: string) => number useDeclaredCursor: (a: { line: number; column: number; active: boolean }) => (el: any) => void @@ -468,10 +470,22 @@ export function TextInput({ return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) } + if (k.ctrl && inp === 'c') { + const range = selRange() + + if (range) { + writeOsc52Clipboard(vRef.current.slice(range.start, range.end)) + clearSel() + + return + } + + return + } + if ( k.upArrow || k.downArrow || - (k.ctrl && inp === 'c') || k.tab || (k.shift && k.tab) || k.pageUp || From 4caf6c23dd8233effe9d39e65fb7160553f917c4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 14:46:38 -0500 Subject: [PATCH 19/23] =?UTF-8?q?fix(tui):=20strip=20=E2=80=A6=20tags=20from=20assistant=20content=20and=20route=20to=20re?= =?UTF-8?q?asoning=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models that emit reasoning inline as //// tags in the content field (rather than a separate API reasoning channel) had the raw tags + inner content shown twice: once as body text with literal markers, and again in the thinking panel when the reasoning field was populated. Port v1's tag set to lib/reasoning.ts with a splitReasoning(text) helper that returns { reasoning, text }. Applied in three spots: - scheduleStreaming: strips tags from the live streaming view so the user never sees mid-turn. - flushStreamingSegment: when a tool interrupts assistant output mid-turn, the saved segment is the stripped text; extracted reasoning promotes to reasoningText if the API channel hasn't already populated it. - recordMessageComplete: final message text is split, extracted reasoning merges with any existing reasoning (API channel wins on conflicts so we don't double-count when both are present). --- ui-tui/src/__tests__/reasoning.test.ts | 50 ++++++++++++++++++++++++++ ui-tui/src/app/turnController.ts | 35 +++++++++++++----- ui-tui/src/lib/reasoning.ts | 50 ++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 ui-tui/src/__tests__/reasoning.test.ts create mode 100644 ui-tui/src/lib/reasoning.ts 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/app/turnController.ts b/ui-tui/src/app/turnController.ts index de57b2dd05..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, @@ -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 @@ -355,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/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 +} From 450ded98dbbe37de125ef387288aff1a19111ab5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 15:13:31 -0500 Subject: [PATCH 20/23] chore(tui): prettier whitespace on files touched in this branch --- ui-tui/src/components/prompts.tsx | 4 +++- ui-tui/src/components/textInput.tsx | 10 +--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index bfc603c51c..c7ced5b31d 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -53,7 +53,9 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) { ))} {overflow > 0 ? ( - … +{overflow} more line{overflow === 1 ? '' : 's'} (full text above) + + … +{overflow} more line{overflow === 1 ? '' : 's'} (full text above) + ) : null} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index a0f7c42f3b..3f45648212 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -483,15 +483,7 @@ export function TextInput({ return } - if ( - k.upArrow || - k.downArrow || - k.tab || - (k.shift && k.tab) || - k.pageUp || - k.pageDown || - k.escape - ) { + if (k.upArrow || k.downArrow || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) { return } From 7e9a09857426f7acc66546188dd37802dd0a9920 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 15:17:42 -0500 Subject: [PATCH 21/23] chore: uptick --- ui-tui/src/app/slash/commands/core.ts | 35 ++++++++++++++++++--------- ui-tui/src/components/messageLine.tsx | 4 ++- 2 files changed, 27 insertions(+), 12 deletions(-) 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/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} From 17e95a26b72b1ea296cdda41587b7f38d27fe72d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 15:22:43 -0500 Subject: [PATCH 22/23] fix(tui): render /skills browse as a formatted Panel instead of raw JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous handler dumped the raw skills.manage response into a pager, which was unreadable and hid the pagination metadata. Also silently accepted non-numeric page args. Now: - validates page arg (rejects NaN / <1 with a usage message) - shows "fetching community skills (scans 6 sources, may take ~15s)…" up front so the 10-30s hub fetch isn't a silent hang - renders items as {name · trust, description (truncated 160 chars)} rows in the existing Panel component - footer shows "page X of Y · N skills total · /skills browse N+1 for more" when the server returned pagination metadata Skills hub's remote fetch latency is a separate upstream issue (browse_skills hits 6 sources sequentially) — client-side we just stop misrepresenting it. --- ui-tui/src/app/slash/commands/ops.ts | 58 +++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index d941c5af41..26318b3fb0 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -27,6 +27,20 @@ interface SkillsInstallResponse { 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', @@ -139,13 +153,47 @@ export const opsCommands: SlashCommand[] = [ } if (sub === 'browse') { - const pageNum = parseInt(query, 10) || 1 + const pageNum = query ? parseInt(query, 10) : 1 - rpc>('skills.manage', { action: 'browse', page: pageNum }) + 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 => - page(JSON.stringify(r, null, 2).slice(0, 4000), `Browse Skills — p${pageNum}`) - ) + 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) From fb06bc67debf74ba53de7ebc90e2d6755ae0e973 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 16:28:51 -0500 Subject: [PATCH 23/23] fix(tui): Ctrl+C with input selection actually preserves input (lift handler to app level) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous fix in 9dbf1ec6 handled Ctrl+C inside textInput but the APP-level useInputHandlers fires the same keypress in a separate React hook and ran clearIn() regardless. Net effect: the OSC 52 copy succeeded but the input wiped right after, so Brooklyn only noticed the wipe. Lift the selection-aware Ctrl+C to a single place by threading input selection state through a new nanostore (src/app/inputSelectionStore.ts). textInput syncs its derived `selected` range + a clear() callback to the store on every selection change, and the app-level Ctrl+C handler reads the store before its clear/interrupt/die chain: - terminal-level selection (scrollback) → copy, existing behavior - in-input selection present → copy + clear selection, preserve input - input has text, no selection → clearIn(), existing behavior - empty + busy → interrupt turn - empty + idle → die textInput no longer has its own Ctrl+C block; keypress falls through to app-level like it did before 9dbf1ec6. --- ui-tui/src/app/inputSelectionStore.ts | 14 ++++++++ ui-tui/src/app/useInputHandlers.ts | 12 +++++++ ui-tui/src/components/textInput.tsx | 48 ++++++++++++++++++--------- 3 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 ui-tui/src/app/inputSelectionStore.ts 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/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 0279a203ca..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' @@ -247,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/textInput.tsx b/ui-tui/src/components/textInput.tsx index 3f45648212..dff8121b5e 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -2,7 +2,7 @@ import type { InputEvent, Key } from '@hermes/ink' import * as Ink from '@hermes/ink' import { useEffect, useMemo, useRef, useState } from 'react' -import { writeOsc52Clipboard } from '../lib/osc52.js' +import { setInputSelection } from '../app/inputSelectionStore.js' type InkExt = typeof Ink & { stringWidth: (s: string) => number @@ -353,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) { @@ -470,20 +492,16 @@ export function TextInput({ return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) } - if (k.ctrl && inp === 'c') { - const range = selRange() - - if (range) { - writeOsc52Clipboard(vRef.current.slice(range.start, range.end)) - clearSel() - - return - } - - return - } - - if (k.upArrow || k.downArrow || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) { + if ( + k.upArrow || + k.downArrow || + (k.ctrl && inp === 'c') || + k.tab || + (k.shift && k.tab) || + k.pageUp || + k.pageDown || + k.escape + ) { return }