diff --git a/ui-tui/src/__tests__/constants.test.ts b/ui-tui/src/__tests__/constants.test.ts index 36ca9a0ad6..d069d24c2d 100644 --- a/ui-tui/src/__tests__/constants.test.ts +++ b/ui-tui/src/__tests__/constants.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import { FACES, HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, ROLE, TOOL_VERBS, VERBS, ZERO } from '../constants.js' +import { FACES } from '../content/faces.js' +import { HOTKEYS } from '../content/hotkeys.js' +import { PLACEHOLDERS } from '../content/placeholders.js' +import { TOOL_VERBS, VERBS } from '../content/verbs.js' +import { ROLE } from '../domain/roles.js' +import { ZERO } from '../domain/usage.js' +import { INTERPOLATION_RE } from '../protocol/interpolation.js' import { DEFAULT_THEME } from '../theme.js' describe('constants', () => { diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index c4f5628ca7..63675b8d3b 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -2,115 +2,55 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js' import { resetOverlayState } from '../app/overlayStore.js' -import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' +import { turnController } from '../app/turnController.js' +import { resetTurnState } from '../app/turnStore.js' +import { resetUiState } from '../app/uiStore.js' import { estimateTokensRough } from '../lib/text.js' import type { Msg } from '../types.js' const ref = (current: T) => ({ current }) +const buildCtx = (appended: Msg[]) => + ({ + composer: { + dequeue: () => undefined, + queueEditRef: ref(null), + sendQueued: vi.fn() + }, + gateway: { + gw: { request: vi.fn() }, + rpc: vi.fn(async () => null) + }, + session: { + STARTUP_RESUME_ID: '', + colsRef: ref(80), + newSession: vi.fn(), + resetSession: vi.fn(), + setCatalog: vi.fn() + }, + system: { + bellOnComplete: false, + sys: vi.fn() + }, + transcript: { + appendMessage: (msg: Msg) => appended.push(msg), + setHistoryItems: vi.fn() + } + }) as any + describe('createGatewayEventHandler', () => { beforeEach(() => { resetOverlayState() resetUiState() + resetTurnState() + turnController.fullReset() }) it('persists completed tool rows when message.complete lands immediately after tool.complete', () => { const appended: Msg[] = [] - const state = { - activity: [] as unknown[], - reasoningTokens: 0, - streaming: '', - toolTokens: 0, - tools: [] as unknown[], - turnTrail: [] as string[] - } - - const setTools = vi.fn((next: unknown) => { - if (typeof next !== 'function') { - state.tools = next as unknown[] - } - }) - - const setTurnTrail = vi.fn((next: unknown) => { - if (typeof next !== 'function') { - state.turnTrail = next as string[] - } - }) - - const refs = { - activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), - bufRef: ref(''), - interruptedRef: ref(false), - lastStatusNoteRef: ref(''), - persistedToolLabelsRef: ref(new Set()), - protocolWarnedRef: ref(false), - reasoningRef: ref('mapped the page'), - statusTimerRef: ref | null>(null), - toolTokenAccRef: ref(0), - toolCompleteRibbonRef: ref(null), - turnToolsRef: ref([] as string[]) - } - - const onEvent = createGatewayEventHandler({ - composer: { - dequeue: () => undefined, - queueEditRef: ref(null), - sendQueued: vi.fn() - }, - gateway: { - gw: { request: vi.fn() } as any, - rpc: vi.fn(async () => null) - }, - session: { - STARTUP_RESUME_ID: '', - colsRef: ref(80), - newSession: vi.fn(), - resetSession: vi.fn(), - setCatalog: vi.fn() - }, - system: { - bellOnComplete: false, - sys: vi.fn() - }, - transcript: { - appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn() - }, - turn: { - actions: { - clearReasoning: vi.fn(() => { - refs.reasoningRef.current = '' - refs.toolTokenAccRef.current = 0 - state.toolTokens = 0 - }), - endReasoningPhase: vi.fn(), - idle: vi.fn(() => { - refs.activeToolsRef.current = [] - state.tools = [] - }), - pruneTransient: vi.fn(), - pulseReasoningStreaming: vi.fn(), - pushActivity: vi.fn(), - pushTrail: vi.fn(), - scheduleReasoning: vi.fn(), - scheduleStreaming: vi.fn(), - setActivity: vi.fn(), - setReasoningTokens: vi.fn((next: number) => { - state.reasoningTokens = next - }), - setStreaming: vi.fn((next: string) => { - state.streaming = next - }), - setToolTokens: vi.fn((next: number) => { - state.toolTokens = next - }), - setTools, - setTurnTrail - }, - refs - } - } as any) + turnController.reasoningText = 'mapped the page' + const onEvent = createGatewayEventHandler(buildCtx(appended)) onEvent({ payload: { context: 'home page', name: 'search', tool_id: 'tool-1' }, @@ -143,104 +83,14 @@ describe('createGatewayEventHandler', () => { it('keeps tool tokens across handler recreation mid-turn', () => { const appended: Msg[] = [] - const state = { - activity: [] as unknown[], - reasoningTokens: 0, - streaming: '', - toolTokens: 0, - tools: [] as unknown[], - turnTrail: [] as string[] - } + turnController.reasoningText = 'mapped the page' - const refs = { - activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), - bufRef: ref(''), - interruptedRef: ref(false), - lastStatusNoteRef: ref(''), - persistedToolLabelsRef: ref(new Set()), - protocolWarnedRef: ref(false), - reasoningRef: ref('mapped the page'), - statusTimerRef: ref | null>(null), - toolTokenAccRef: ref(0), - toolCompleteRibbonRef: ref(null), - turnToolsRef: ref([] as string[]) - } - - const buildHandler = () => - createGatewayEventHandler({ - composer: { - dequeue: () => undefined, - queueEditRef: ref(null), - sendQueued: vi.fn() - }, - gateway: { - gw: { request: vi.fn() } as any, - rpc: vi.fn(async () => null) - }, - session: { - STARTUP_RESUME_ID: '', - colsRef: ref(80), - newSession: vi.fn(), - resetSession: vi.fn(), - setCatalog: vi.fn() - }, - system: { - bellOnComplete: false, - sys: vi.fn() - }, - transcript: { - appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn() - }, - turn: { - actions: { - clearReasoning: vi.fn(() => { - refs.reasoningRef.current = '' - refs.toolTokenAccRef.current = 0 - state.toolTokens = 0 - }), - endReasoningPhase: vi.fn(), - idle: vi.fn(() => { - refs.activeToolsRef.current = [] - state.tools = [] - }), - pruneTransient: vi.fn(), - pulseReasoningStreaming: vi.fn(), - pushActivity: vi.fn(), - pushTrail: vi.fn(), - scheduleReasoning: vi.fn(), - scheduleStreaming: vi.fn(), - setActivity: vi.fn(), - setReasoningTokens: vi.fn((next: number) => { - state.reasoningTokens = next - }), - setStreaming: vi.fn((next: string) => { - state.streaming = next - }), - setToolTokens: vi.fn((next: number) => { - state.toolTokens = next - }), - setTools: vi.fn((next: unknown) => { - if (typeof next !== 'function') { - state.tools = next as unknown[] - } - }), - setTurnTrail: vi.fn((next: unknown) => { - if (typeof next !== 'function') { - state.turnTrail = next as string[] - } - }) - }, - refs - } - } as any) - - buildHandler()({ + createGatewayEventHandler(buildCtx(appended))({ payload: { context: 'home page', name: 'search', tool_id: 'tool-1' }, type: 'tool.start' } as any) - const onEvent = buildHandler() + const onEvent = createGatewayEventHandler(buildCtx(appended)) onEvent({ payload: { name: 'search', preview: 'hero cards' }, @@ -265,84 +115,11 @@ describe('createGatewayEventHandler', () => { const streamed = 'short streamed reasoning' const fallback = 'x'.repeat(400) - const refs = { - activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), - bufRef: ref(''), - interruptedRef: ref(false), - lastStatusNoteRef: ref(''), - persistedToolLabelsRef: ref(new Set()), - protocolWarnedRef: ref(false), - reasoningRef: ref(''), - statusTimerRef: ref | null>(null), - toolTokenAccRef: ref(0), - toolCompleteRibbonRef: ref(null), - turnToolsRef: ref([] as string[]) - } + const onEvent = createGatewayEventHandler(buildCtx(appended)) - const onEvent = createGatewayEventHandler({ - composer: { - dequeue: () => undefined, - queueEditRef: ref(null), - sendQueued: vi.fn() - }, - gateway: { - gw: { request: vi.fn() } as any, - rpc: vi.fn(async () => null) - }, - session: { - STARTUP_RESUME_ID: '', - colsRef: ref(80), - newSession: vi.fn(), - resetSession: vi.fn(), - setCatalog: vi.fn() - }, - system: { - bellOnComplete: false, - sys: vi.fn() - }, - transcript: { - appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn() - }, - turn: { - actions: { - clearReasoning: vi.fn(() => { - refs.reasoningRef.current = '' - refs.toolTokenAccRef.current = 0 - }), - endReasoningPhase: vi.fn(), - idle: vi.fn(() => { - refs.activeToolsRef.current = [] - }), - pruneTransient: vi.fn(), - pulseReasoningStreaming: vi.fn(), - pushActivity: vi.fn(), - pushTrail: vi.fn(), - scheduleReasoning: vi.fn(), - scheduleStreaming: vi.fn(), - setActivity: vi.fn(), - setReasoningTokens: vi.fn(), - setStreaming: vi.fn(), - setToolTokens: vi.fn(), - setTools: vi.fn(), - setTurnTrail: vi.fn() - }, - refs - } - } as any) - - onEvent({ - payload: { text: streamed }, - type: 'reasoning.delta' - } as any) - onEvent({ - payload: { text: fallback }, - type: 'reasoning.available' - } as any) - onEvent({ - payload: { text: 'final answer' }, - type: 'message.complete' - } as any) + onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any) + onEvent({ payload: { text: fallback }, type: 'reasoning.available' } as any) + onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any) expect(appended).toHaveLength(1) expect(appended[0]?.thinking).toBe(streamed) @@ -353,173 +130,12 @@ describe('createGatewayEventHandler', () => { const appended: Msg[] = [] const fromServer = 'recovered from last_reasoning' - const refs = { - activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), - bufRef: ref(''), - interruptedRef: ref(false), - lastStatusNoteRef: ref(''), - persistedToolLabelsRef: ref(new Set()), - protocolWarnedRef: ref(false), - reasoningRef: ref(''), - statusTimerRef: ref | null>(null), - toolTokenAccRef: ref(0), - toolCompleteRibbonRef: ref(null), - turnToolsRef: ref([] as string[]) - } + const onEvent = createGatewayEventHandler(buildCtx(appended)) - const onEvent = createGatewayEventHandler({ - composer: { - dequeue: () => undefined, - queueEditRef: ref(null), - sendQueued: vi.fn() - }, - gateway: { - gw: { request: vi.fn() } as any, - rpc: vi.fn(async () => null) - }, - session: { - STARTUP_RESUME_ID: '', - colsRef: ref(80), - newSession: vi.fn(), - resetSession: vi.fn(), - setCatalog: vi.fn() - }, - system: { - bellOnComplete: false, - sys: vi.fn() - }, - transcript: { - appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn() - }, - turn: { - actions: { - clearReasoning: vi.fn(() => { - refs.reasoningRef.current = '' - refs.toolTokenAccRef.current = 0 - }), - endReasoningPhase: vi.fn(), - idle: vi.fn(() => { - refs.activeToolsRef.current = [] - }), - pruneTransient: vi.fn(), - pulseReasoningStreaming: vi.fn(), - pushActivity: vi.fn(), - pushTrail: vi.fn(), - scheduleReasoning: vi.fn(), - scheduleStreaming: vi.fn(), - setActivity: vi.fn(), - setReasoningTokens: vi.fn(), - setStreaming: vi.fn(), - setToolTokens: vi.fn(), - setTools: vi.fn(), - setTurnTrail: vi.fn() - }, - refs - } - } as any) - - onEvent({ - payload: { reasoning: fromServer, text: 'final answer' }, - type: 'message.complete' - } as any) + onEvent({ payload: { reasoning: fromServer, text: 'final answer' }, type: 'message.complete' } as any) expect(appended).toHaveLength(1) expect(appended[0]?.thinking).toBe(fromServer) expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer)) }) - - it('merges message.complete usage into existing context fields', () => { - const appended: Msg[] = [] - - patchUiState({ - usage: { - calls: 1, - context_max: 100_000, - context_percent: 12, - context_used: 12_000, - input: 10, - output: 20, - total: 30 - } - }) - - const refs = { - activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), - bufRef: ref(''), - interruptedRef: ref(false), - lastStatusNoteRef: ref(''), - persistedToolLabelsRef: ref(new Set()), - protocolWarnedRef: ref(false), - reasoningRef: ref(''), - statusTimerRef: ref | null>(null), - toolTokenAccRef: ref(0), - toolCompleteRibbonRef: ref(null), - turnToolsRef: ref([] as string[]) - } - - const onEvent = createGatewayEventHandler({ - composer: { - dequeue: () => undefined, - queueEditRef: ref(null), - sendQueued: vi.fn() - }, - gateway: { - gw: { request: vi.fn() } as any, - rpc: vi.fn(async () => null) - }, - session: { - STARTUP_RESUME_ID: '', - colsRef: ref(80), - newSession: vi.fn(), - resetSession: vi.fn(), - setCatalog: vi.fn() - }, - system: { - bellOnComplete: false, - sys: vi.fn() - }, - transcript: { - appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn() - }, - turn: { - actions: { - clearReasoning: vi.fn(() => { - refs.reasoningRef.current = '' - }), - endReasoningPhase: vi.fn(), - idle: vi.fn(), - pruneTransient: vi.fn(), - pulseReasoningStreaming: vi.fn(), - pushActivity: vi.fn(), - pushTrail: vi.fn(), - scheduleReasoning: vi.fn(), - scheduleStreaming: vi.fn(), - setActivity: vi.fn(), - setReasoningTokens: vi.fn(), - setStreaming: vi.fn(), - setToolTokens: vi.fn(), - setTools: vi.fn(), - setTurnTrail: vi.fn() - }, - refs - } - } as any) - - onEvent({ - payload: { - text: 'ok', - usage: { calls: 2, input: 50, output: 60, total: 110 } - }, - type: 'message.complete' - } as any) - - const u = getUiState().usage - expect(u.input).toBe(50) - expect(u.total).toBe(110) - expect(u.context_max).toBe(100_000) - expect(u.context_used).toBe(12_000) - expect(u.context_percent).toBe(12) - }) }) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 4968d74c29..631bd7a350 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,7 +1,7 @@ -import { MOUSE_TRACKING } from './app/constants.js' import { GatewayProvider } from './app/gatewayContext.js' import { useMainApp } from './app/useMainApp.js' import { AppLayout } from './components/appLayout.js' +import { MOUSE_TRACKING } from './config/env.js' import type { GatewayClient } from './gatewayClient.js' export function App({ gw }: { gw: GatewayClient }) { diff --git a/ui-tui/src/app/constants.ts b/ui-tui/src/app/constants.ts deleted file mode 100644 index 335e58d82f..0000000000 --- a/ui-tui/src/app/constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PLACEHOLDERS } from '../constants.js' -import { pick } from '../lib/text.js' - -export const PLACEHOLDER = pick(PLACEHOLDERS) -export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() - -export const LARGE_PASTE = { chars: 8000, lines: 80 } -export const MAX_HISTORY = 800 -export const REASONING_PULSE_MS = 700 -export const STREAM_BATCH_MS = 16 -export const WHEEL_SCROLL_STEP = 3 -export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test( - (process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase() -) -export const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 5541bf513f..f2e08765e6 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,21 +1,43 @@ -import type { CommandsCatalogResponse, GatewayEvent, SessionResumeResponse } from '../gatewayTypes.js' +import { STREAM_BATCH_MS } from '../config/timing.js' +import { introMsg, toTranscriptMessages } from '../domain/messages.js' +import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin, SessionResumeResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' -import { - buildToolTrailLine, - estimateTokensRough, - formatToolCall, - isToolTrailResultLine, - sameToolTrailGroup, - toolTrailLabel -} from '../lib/text.js' +import { formatToolCall } from '../lib/text.js' import { fromSkin } from '../theme.js' +import type { SubagentProgress } from '../types.js' -import { STREAM_BATCH_MS } from './constants.js' -import { introMsg, toTranscriptMessages } from './helpers.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' import { getUiState, patchUiState } from './uiStore.js' +const ERRLIKE_RE = /\b(error|traceback|exception|failed|spawn)\b/i + +const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready') + +const applySkin = (s: GatewaySkin) => + patchUiState({ theme: fromSkin(s.colors ?? {}, s.branding ?? {}, s.banner_logo ?? '', s.banner_hero ?? '') }) + +const dropBgTask = (taskId: string) => + patchUiState(state => { + const next = new Set(state.bgTasks) + next.delete(taskId) + + return { ...state, bgTasks: next } + }) + +const statusToneFrom = (kind: string): 'error' | 'info' | 'warn' => + kind === 'error' ? 'error' : kind === 'warn' || kind === 'approval' ? 'warn' : 'info' + +const pushUnique = + (max: number) => + (xs: T[], x: T): T[] => + xs.at(-1) === x ? xs : [...xs, x].slice(-max) + +const pushThinking = pushUnique(6) +const pushNote = pushUnique(6) +const pushTool = pushUnique(8) + export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { const { dequeue, queueEditRef, sendQueued } = ctx.composer const { gw, rpc } = ctx.gateway @@ -23,53 +45,17 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const { bellOnComplete, stdout, sys } = ctx.system const { appendMessage, setHistoryItems } = ctx.transcript - const { - clearReasoning, - endReasoningPhase, - idle, - pruneTransient, - pulseReasoningStreaming, - pushActivity, - pushTrail, - scheduleReasoning, - scheduleStreaming, - setActivity, - setStreaming, - setSubagents, - setToolTokens, - setTools, - setTurnTrail - } = ctx.turn.actions - - const { - activeToolsRef, - bufRef, - interruptedRef, - lastStatusNoteRef, - persistedToolLabelsRef, - protocolWarnedRef, - reasoningRef, - statusTimerRef, - toolTokenAccRef, - toolCompleteRibbonRef, - turnToolsRef - } = ctx.turn.refs - let pendingThinkingStatus = '' - let thinkingStatusTimer: ReturnType | null = null - let toolProgressTimer: ReturnType | null = null + let thinkingStatusTimer: null | ReturnType = null - const cancelThinkingStatus = () => { + const setStatus = (status: string) => { pendingThinkingStatus = '' if (thinkingStatusTimer) { clearTimeout(thinkingStatusTimer) thinkingStatusTimer = null } - } - const setStatus = (status: string) => { - cancelThinkingStatus() patchUiState({ status }) } @@ -82,79 +68,77 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: thinkingStatusTimer = setTimeout(() => { thinkingStatusTimer = null - patchUiState({ status: pendingThinkingStatus || (getUiState().busy ? 'running…' : 'ready') }) + patchUiState({ status: pendingThinkingStatus || statusFromBusy() }) }, STREAM_BATCH_MS) } - const scheduleToolProgress = () => { - if (toolProgressTimer) { + const restoreStatusAfter = (ms: number) => { + turnController.clearStatusTimer() + turnController.statusTimer = setTimeout(() => { + turnController.statusTimer = null + patchUiState({ status: statusFromBusy() }) + }, ms) + } + + const keepCompletedElseRunning = (s: SubagentProgress['status']) => (s === 'completed' ? s : 'running') + + const handleReady = (skin?: GatewaySkin) => { + if (skin) { + applySkin(skin) + } + + rpc('commands.catalog', {}) + .then(r => { + if (!r?.pairs) { + return + } + + setCatalog({ + canon: (r.canon ?? {}) as Record, + categories: r.categories ?? [], + pairs: r.pairs as [string, string][], + skillCount: (r.skill_count ?? 0) as number, + sub: (r.sub ?? {}) as Record + }) + + if (r.warning) { + turnController.pushActivity(String(r.warning), 'warn') + } + }) + .catch((e: unknown) => turnController.pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn')) + + if (!STARTUP_RESUME_ID) { + patchUiState({ status: 'forging session…' }) + newSession() + return } - toolProgressTimer = setTimeout(() => { - toolProgressTimer = null - setTools([...activeToolsRef.current]) - }, STREAM_BATCH_MS) - } + patchUiState({ status: 'resuming…' }) + gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) + .then(raw => { + const r = asRpcResult(raw) - const upsertSubagent = ( - taskIndex: number, - taskCount: number, - goal: string, - update: (current: { - durationSeconds?: number - goal: string - id: string - index: number - notes: string[] - status: 'completed' | 'failed' | 'interrupted' | 'running' - summary?: string - taskCount: number - thinking: string[] - tools: string[] - }) => { - durationSeconds?: number - goal: string - id: string - index: number - notes: string[] - status: 'completed' | 'failed' | 'interrupted' | 'running' - summary?: string - taskCount: number - thinking: string[] - tools: string[] - } - ) => { - const id = `sa:${taskIndex}:${goal || 'subagent'}` + if (!r) { + throw new Error('invalid response: session.resume') + } - setSubagents(prev => { - const index = prev.findIndex(item => item.id === id) - - const base = - index >= 0 - ? prev[index]! - : { - id, - index: taskIndex, - taskCount, - goal, - notes: [], - status: 'running' as const, - thinking: [], - tools: [] - } - - const nextItem = update(base) - - if (index < 0) { - return [...prev, nextItem].sort((a, b) => a.index - b.index) - } - - const next = [...prev] - next[index] = nextItem - - return next - }) + resetSession() + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: r.info?.usage ?? getUiState().usage + }) + setHistoryItems( + r.info ? [introMsg(r.info), ...toTranscriptMessages(r.messages)] : toTranscriptMessages(r.messages) + ) + }) + .catch((e: unknown) => { + sys(`resume failed: ${rpcErrorMessage(e)}`) + patchUiState({ status: 'forging session…' }) + newSession('started a new session') + }) } return (ev: GatewayEvent) => { @@ -165,483 +149,240 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } switch (ev.type) { - case 'gateway.ready': { - const p = ev.payload + case 'gateway.ready': + handleReady(ev.payload?.skin) - if (p?.skin) { - patchUiState({ - theme: fromSkin( - p.skin.colors ?? {}, - p.skin.branding ?? {}, - p.skin.banner_logo ?? '', - p.skin.banner_hero ?? '' - ) - }) + return + + case 'skin.changed': + if (ev.payload) { + applySkin(ev.payload) } - rpc('commands.catalog', {}) - .then(r => { - if (!r?.pairs) { - return - } - - setCatalog({ - canon: (r.canon ?? {}) as Record, - categories: r.categories ?? [], - pairs: r.pairs as [string, string][], - skillCount: (r.skill_count ?? 0) as number, - sub: (r.sub ?? {}) as Record - }) - - if (r.warning) { - pushActivity(String(r.warning), 'warn') - } - }) - .catch((e: unknown) => pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn')) - - if (STARTUP_RESUME_ID) { - patchUiState({ status: 'resuming…' }) - gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) - .then(raw => { - const r = asRpcResult(raw) - - if (!r) { - throw new Error('invalid response: session.resume') - } - - resetSession() - const resumed = toTranscriptMessages(r.messages) - - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: r.info?.usage ?? getUiState().usage - }) - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - }) - .catch((e: unknown) => { - sys(`resume failed: ${rpcErrorMessage(e)}`) - patchUiState({ status: 'forging session…' }) - newSession('started a new session') - }) - } else { - patchUiState({ status: 'forging session…' }) - newSession() - } - - break - } - - case 'skin.changed': { - const p = ev.payload - - if (p) { - patchUiState({ - theme: fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '') - }) - } - - break - } - - case 'session.info': { - const p = ev.payload + return + case 'session.info': patchUiState(state => ({ ...state, - info: p, - usage: p.usage ? { ...state.usage, ...p.usage } : state.usage + info: ev.payload, + usage: ev.payload.usage ? { ...state.usage, ...ev.payload.usage } : state.usage })) - break - } - + return case 'thinking.delta': { - const p = ev.payload + const text = ev.payload?.text - if (p && Object.prototype.hasOwnProperty.call(p, 'text')) { - scheduleThinkingStatus(p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready') + if (text !== undefined) { + scheduleThinkingStatus(text ? String(text) : statusFromBusy()) } - break + return } case 'message.start': - patchUiState({ busy: true }) - endReasoningPhase() - clearReasoning() - setActivity([]) - setSubagents([]) - setTurnTrail([]) - activeToolsRef.current = [] - setTools([]) - turnToolsRef.current = [] - persistedToolLabelsRef.current.clear() - toolTokenAccRef.current = 0 - setToolTokens(0) + turnController.startMessage() - break + return case 'status.update': { const p = ev.payload - if (p?.text) { - setStatus(p.text) - - if (p.kind && p.kind !== 'status') { - if (lastStatusNoteRef.current !== p.text) { - lastStatusNoteRef.current = p.text - pushActivity( - p.text, - p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' - ) - } - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - } - - statusTimerRef.current = setTimeout(() => { - statusTimerRef.current = null - patchUiState({ status: getUiState().busy ? 'running…' : 'ready' }) - }, 4000) - } + if (!p?.text) { + return } - break + setStatus(p.text) + + if (!p.kind || p.kind === 'status') { + return + } + + if (turnController.lastStatusNote !== p.text) { + turnController.lastStatusNote = p.text + turnController.pushActivity(p.text, statusToneFrom(p.kind)) + } + + restoreStatusAfter(4000) + + return } case 'gateway.stderr': { - const p = ev.payload + const line = String(ev.payload.line).slice(0, 120) - if (p?.line) { - const line = String(p.line).slice(0, 120) - const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn' + turnController.pushActivity(line, ERRLIKE_RE.test(line) ? 'error' : 'warn') - pushActivity(line, tone) - } - - break + return } case 'gateway.start_timeout': { - const p = ev.payload + const { cwd, python } = ev.payload ?? {} + const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : '' setStatus('gateway startup timeout') - pushActivity( - `gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`, - 'error' - ) + turnController.pushActivity(`gateway startup timed out${trace} · /logs to inspect`, 'error') - break + return } - case 'gateway.protocol_error': { - const p = ev.payload - + case 'gateway.protocol_error': setStatus('protocol warning') + restoreStatusAfter(4000) - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) + if (!turnController.protocolWarned) { + turnController.protocolWarned = true + turnController.pushActivity('protocol noise detected · /logs to inspect', 'warn') } - statusTimerRef.current = setTimeout(() => { - statusTimerRef.current = null - patchUiState({ status: getUiState().busy ? 'running…' : 'ready' }) - }, 4000) - - if (!protocolWarnedRef.current) { - protocolWarnedRef.current = true - pushActivity('protocol noise detected · /logs to inspect', 'warn') + if (ev.payload?.preview) { + turnController.pushActivity(`protocol noise: ${String(ev.payload.preview).slice(0, 120)}`, 'warn') } - if (p?.preview) { - pushActivity(`protocol noise: ${String(p.preview).slice(0, 120)}`, 'warn') + return + + case 'reasoning.delta': + if (ev.payload?.text) { + turnController.recordReasoningDelta(ev.payload.text) } - break - } + return - case 'reasoning.delta': { - const p = ev.payload + case 'reasoning.available': + turnController.recordReasoningAvailable(String(ev.payload?.text ?? '')) - if (p?.text) { - reasoningRef.current += p.text - scheduleReasoning() - pulseReasoningStreaming() + return + + case 'tool.progress': + if (ev.payload?.preview && ev.payload.name) { + turnController.recordToolProgress(ev.payload.name, ev.payload.preview) } - break - } + return - case 'reasoning.available': { - const p = ev.payload - const incoming = String(p?.text ?? '').trim() - - if (!incoming) { - break + case 'tool.generating': + if (ev.payload?.name) { + turnController.pushTrail(`drafting ${ev.payload.name}…`) } - const current = reasoningRef.current.trim() + return - // `reasoning.available` is a backend fallback preview that can arrive after - // streamed reasoning. Preserve the live-visible reasoning/counts if we - // already saw deltas; only hydrate from this event when streaming gave us - // nothing. - if (!current) { - reasoningRef.current = incoming - scheduleReasoning() - pulseReasoningStreaming() + case 'tool.start': + turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '') + + return + + case 'tool.complete': + turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary) + + if (ev.payload.inline_diff) { + sys(ev.payload.inline_diff) } - break - } + return - case 'tool.progress': { - const p = ev.payload - - if (p?.preview) { - const index = activeToolsRef.current.findIndex(tool => tool.name === p.name) - - if (index >= 0) { - const next = [...activeToolsRef.current] - - next[index] = { ...next[index]!, context: p.preview as string } - activeToolsRef.current = next - scheduleToolProgress() - } - } - - break - } - - case 'tool.generating': { - const p = ev.payload - - if (p?.name) { - pushTrail(`drafting ${p.name}…`) - } - - break - } - - case 'tool.start': { - const p = ev.payload - pruneTransient() - endReasoningPhase() - const name = p.name ?? 'tool' - const ctx = p.context ?? '' - const sample = `${String(p.name ?? '')} ${ctx}`.trim() - toolTokenAccRef.current += sample ? estimateTokensRough(sample) : 0 - setToolTokens(toolTokenAccRef.current) - activeToolsRef.current = [ - ...activeToolsRef.current, - { id: p.tool_id, name, context: ctx, startedAt: Date.now() } - ] - setTools(activeToolsRef.current) - - break - } - - case 'tool.complete': { - const p = ev.payload - toolCompleteRibbonRef.current = null - const done = activeToolsRef.current.find(tool => tool.id === p.tool_id) - const name = done?.name ?? p.name ?? 'tool' - const label = toolTrailLabel(name) - - const line = buildToolTrailLine(name, done?.context || '', !!p.error, p.error || p.summary || '') - - const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line] - - activeToolsRef.current = activeToolsRef.current.filter(tool => tool.id !== p.tool_id) - setTools(activeToolsRef.current) - toolCompleteRibbonRef.current = { label, line } - - if (!activeToolsRef.current.length) { - next.push('analyzing tool output…') - } - - turnToolsRef.current = next.slice(-8) - setTurnTrail(turnToolsRef.current) - - if (p?.inline_diff) { - sys(p.inline_diff) - } - - break - } - - case 'clarify.request': { - const p = ev.payload - patchOverlayState({ clarify: { choices: p.choices, question: p.question, requestId: p.request_id } }) + case 'clarify.request': + patchOverlayState({ + clarify: { choices: ev.payload.choices, question: ev.payload.question, requestId: ev.payload.request_id } + }) setStatus('waiting for input…') - break - } + return - case 'approval.request': { - const p = ev.payload - patchOverlayState({ approval: { command: p.command, description: p.description } }) + case 'approval.request': + patchOverlayState({ approval: { command: ev.payload.command, description: ev.payload.description } }) setStatus('approval needed') - break - } + return - case 'sudo.request': { - const p = ev.payload - patchOverlayState({ sudo: { requestId: p.request_id } }) + case 'sudo.request': + patchOverlayState({ sudo: { requestId: ev.payload.request_id } }) setStatus('sudo password needed') - break - } + return - case 'secret.request': { - const p = ev.payload - patchOverlayState({ secret: { envVar: p.env_var, prompt: p.prompt, requestId: p.request_id } }) + case 'secret.request': + patchOverlayState({ + secret: { envVar: ev.payload.env_var, prompt: ev.payload.prompt, requestId: ev.payload.request_id } + }) setStatus('secret input needed') - break - } + return - case 'background.complete': { - const p = ev.payload - patchUiState(state => { - const next = new Set(state.bgTasks) + case 'background.complete': + dropBgTask(ev.payload.task_id) + sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`) - next.delete(p.task_id) + return - return { ...state, bgTasks: next } - }) - sys(`[bg ${p.task_id}] ${p.text}`) + case 'btw.complete': + dropBgTask('btw:x') + sys(`[btw] ${ev.payload.text}`) - break - } + return - case 'btw.complete': { - const p = ev.payload - patchUiState(state => { - const next = new Set(state.bgTasks) - - next.delete('btw:x') - - return { ...state, bgTasks: next } - }) - sys(`[btw] ${p.text}`) - - break - } - - case 'subagent.start': { - const p = ev.payload - - upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ - ...current, - goal: p.goal || current.goal, - status: 'running', - taskCount: p.task_count ?? current.taskCount - })) - - break - } + case 'subagent.start': + turnController.upsertSubagent(ev.payload, () => ({ status: 'running' })) + return case 'subagent.thinking': { - const p = ev.payload - const text = String(p.text ?? '').trim() + const text = String(ev.payload.text ?? '').trim() if (!text) { - break + return } - upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ - ...current, - goal: p.goal || current.goal, - status: current.status === 'completed' ? current.status : 'running', - taskCount: p.task_count ?? current.taskCount, - thinking: current.thinking.at(-1) === text ? current.thinking : [...current.thinking, text].slice(-6) + turnController.upsertSubagent(ev.payload, c => ({ + status: keepCompletedElseRunning(c.status), + thinking: pushThinking(c.thinking, text) })) - break + return } case 'subagent.tool': { - const p = ev.payload - const line = formatToolCall(p.tool_name ?? 'delegate_task', p.tool_preview ?? p.text ?? '') + const line = formatToolCall( + ev.payload.tool_name ?? 'delegate_task', + ev.payload.tool_preview ?? ev.payload.text ?? '' + ) - upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ - ...current, - goal: p.goal || current.goal, - status: current.status === 'completed' ? current.status : 'running', - taskCount: p.task_count ?? current.taskCount, - tools: current.tools.at(-1) === line ? current.tools : [...current.tools, line].slice(-8) + turnController.upsertSubagent(ev.payload, c => ({ + status: keepCompletedElseRunning(c.status), + tools: pushTool(c.tools, line) })) - break + return } case 'subagent.progress': { - const p = ev.payload - const text = String(p.text ?? '').trim() + const text = String(ev.payload.text ?? '').trim() if (!text) { - break + return } - upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ - ...current, - goal: p.goal || current.goal, - status: current.status === 'completed' ? current.status : 'running', - taskCount: p.task_count ?? current.taskCount, - notes: current.notes.at(-1) === text ? current.notes : [...current.notes, text].slice(-6) + turnController.upsertSubagent(ev.payload, c => ({ + notes: pushNote(c.notes, text), + status: keepCompletedElseRunning(c.status) })) - break + return } - case 'subagent.complete': { - const p = ev.payload - const status = p.status ?? 'completed' - - upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ - ...current, - durationSeconds: p.duration_seconds ?? current.durationSeconds, - goal: p.goal || current.goal, - status, - summary: p.summary || p.text || current.summary, - taskCount: p.task_count ?? current.taskCount + case 'subagent.complete': + turnController.upsertSubagent(ev.payload, c => ({ + durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds, + status: ev.payload.status ?? 'completed', + summary: ev.payload.summary || ev.payload.text || c.summary })) - break - } + return - case 'message.delta': { - const p = ev.payload - pruneTransient() - endReasoningPhase() - - if (p?.text && !interruptedRef.current) { - bufRef.current = p.rendered ?? bufRef.current + p.text - scheduleStreaming() - } - - break - } + case 'message.delta': + turnController.recordMessageDelta(ev.payload ?? {}) + return case 'message.complete': { - const p = ev.payload - const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart() - const persisted = persistedToolLabelsRef.current - const streamedReasoning = reasoningRef.current.trim() - const payloadReasoning = String(p?.reasoning ?? '').trim() - const savedReasoning = streamedReasoning || payloadReasoning - const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 - const savedToolTokens = toolTokenAccRef.current - - const savedTools = turnToolsRef.current.filter( - line => isToolTrailResultLine(line) && ![...persisted].some(item => sameToolTrailGroup(item, line)) - ) - - const wasInterrupted = interruptedRef.current + const { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted } = + turnController.recordMessageComplete(ev.payload ?? {}) if (!wasInterrupted) { appendMessage({ @@ -658,21 +399,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } } - idle() - clearReasoning() - - turnToolsRef.current = [] - persistedToolLabelsRef.current.clear() - setActivity([]) - bufRef.current = '' setStatus('ready') - if (p?.usage) { - patchUiState(state => ({ ...state, usage: { ...state.usage, ...p.usage } })) + if (ev.payload?.usage) { + patchUiState(state => ({ ...state, usage: { ...state.usage, ...ev.payload!.usage } })) } if (queueEditRef.current !== null) { - break + return } const next = dequeue() @@ -681,27 +415,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: sendQueued(next) } - break + return } - case 'error': { - const p = ev.payload - idle() - clearReasoning() - turnToolsRef.current = [] - persistedToolLabelsRef.current.clear() - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - statusTimerRef.current = null - } - - pushActivity(String(p?.message || 'unknown error'), 'error') - sys(`error: ${p?.message}`) + case 'error': + turnController.recordError() + turnController.pushActivity(String(ev.payload?.message || 'unknown error'), 'error') + sys(`error: ${ev.payload?.message}`) setStatus('ready') - - break - } } } } diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 1a23943c09..c3ddab7a29 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,12 +1,11 @@ +import { parseSlashCommand } from '../domain/slash.js' import type { SlashExecResponse } from '../gatewayTypes.js' import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js' import type { SlashHandlerContext } from './interfaces.js' -import { createSlashCoreHandler } from './slash/createSlashCoreHandler.js' -import { createSlashOpsHandler } from './slash/createSlashOpsHandler.js' -import { createSlashSessionHandler } from './slash/createSlashSessionHandler.js' -import { isStaleSlash } from './slash/isStaleSlash.js' -import { createSlashShared, parseSlashCommand } from './slash/shared.js' +import { findSlashCommand } from './slash/registry.js' +import { createSlashShared } from './slash/shared.js' +import type { SlashRunCtx } from './slash/types.js' import { getUiState } from './uiStore.js' export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { @@ -14,18 +13,37 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b const { catalog } = ctx.local const { send, sys } = ctx.transcript const shared = createSlashShared({ ...ctx.transcript, gw, slashFlightRef: ctx.slashFlightRef }) - const handleCore = createSlashCoreHandler(ctx) - const handleSession = createSlashSessionHandler(ctx, shared) - const handleOps = createSlashOpsHandler(ctx) const handler = (cmd: string): boolean => { const flight = ++ctx.slashFlightRef.current const ui = getUiState() - const sidAtSend = ui.sid - const parsed = { ...parseSlashCommand(cmd), flight, sid: sidAtSend, ui } + const sid = ui.sid + const parsed = parseSlashCommand(cmd) const argTail = parsed.arg ? ` ${parsed.arg}` : '' - if (handleCore(parsed) || handleSession(parsed) || handleOps(parsed)) { + const stale = () => flight !== ctx.slashFlightRef.current || getUiState().sid !== sid + + const guarded = + (fn: (r: T) => void) => + (r: null | T): void => { + if (!stale() && r) { + fn(r) + } + } + + const guardedErr = (e: unknown) => { + if (!stale()) { + sys(`error: ${rpcErrorMessage(e)}`) + } + } + + const runCtx: SlashRunCtx = { ...ctx, flight, guarded, guardedErr, shared, sid, stale, ui } + + const found = findSlashCommand(parsed.name) + + if (found) { + found.run(parsed.arg, runCtx, cmd) + return true } @@ -51,9 +69,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b } } - gw.request('slash.exec', { command: cmd.slice(1), session_id: sidAtSend }) + gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then(r => { - if (isStaleSlash(ctx, flight, sidAtSend)) { + if (stale()) { return } @@ -64,41 +82,33 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b ) }) .catch(() => { - gw.request('command.dispatch', { name: parsed.name, arg: parsed.arg, session_id: sidAtSend }) + gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid }) .then((raw: unknown) => { - if (isStaleSlash(ctx, flight, sidAtSend)) { + if (stale()) { return } const d = asCommandDispatch(raw) if (!d) { - sys('error: invalid response: command.dispatch') - - return + return sys('error: invalid response: command.dispatch') } if (d.type === 'exec' || d.type === 'plugin') { - sys(d.output || '(no output)') - } else if (d.type === 'alias') { - handler(`/${d.target}${argTail}`) - } else if (d.type === 'skill') { + return sys(d.output || '(no output)') + } + + if (d.type === 'alias') { + return handler(`/${d.target}${argTail}`) + } + + if (d.type === 'skill') { sys(`⚡ loading skill: ${d.name}`) - if (typeof d.message === 'string' && d.message.trim()) { - send(d.message) - } else { - sys(`/${parsed.name}: skill payload missing message`) - } + return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`) } }) - .catch((e: unknown) => { - if (isStaleSlash(ctx, flight, sidAtSend)) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) + .catch(guardedErr) }) return true diff --git a/ui-tui/src/app/helpers.ts b/ui-tui/src/app/helpers.ts deleted file mode 100644 index 8496008c7a..0000000000 --- a/ui-tui/src/app/helpers.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { buildToolTrailLine, fmtK, userDisplay } from '../lib/text.js' -import type { DetailsMode, Msg, SessionInfo } from '../types.js' - -const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] - -export const parseDetailsMode = (v: unknown): DetailsMode | null => { - const s = typeof v === 'string' ? v.trim().toLowerCase() : '' - - return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null -} - -export const resolveDetailsMode = (d: any): DetailsMode => - parseDetailsMode(d?.details_mode) ?? - { full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[ - String(d?.thinking_mode ?? '') - .trim() - .toLowerCase() - ] ?? - 'collapsed' - -export const nextDetailsMode = (m: DetailsMode): DetailsMode => - DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]! - -export const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) - -export const shortCwd = (cwd: string, max = 28) => { - const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd - - return p.length <= max ? p : `…${p.slice(-(max - 1))}` -} - -export const imageTokenMeta = ( - info: { height?: number; token_estimate?: number; width?: number } | null | undefined -) => { - const dims = info?.width && info?.height ? `${info.width}x${info.height}` : '' - - const tok = - typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : '' - - return [dims, tok].filter(Boolean).join(' · ') -} - -export const looksLikeSlashCommand = (text: string) => { - if (!text.startsWith('/')) { - return false - } - - const first = text.split(/\s+/, 1)[0] || '' - - return !first.slice(1).includes('/') -} - -export const toTranscriptMessages = (rows: unknown): Msg[] => { - if (!Array.isArray(rows)) { - return [] - } - - const result: Msg[] = [] - let pendingTools: string[] = [] - - for (const row of rows) { - if (!row || typeof row !== 'object') { - continue - } - - const role = (row as any).role - const text = (row as any).text - - if (role === 'tool') { - const name = (row as any).name ?? 'tool' - const ctx = (row as any).context ?? '' - pendingTools.push(buildToolTrailLine(name, ctx)) - - continue - } - - if (typeof text !== 'string' || !text.trim()) { - continue - } - - if (role === 'assistant') { - const msg: Msg = { role, text } - - if (pendingTools.length) { - msg.tools = pendingTools - pendingTools = [] - } - - result.push(msg) - - continue - } - - if (role === 'user' || role === 'system') { - pendingTools = [] - result.push({ role, text }) - } - } - - return result -} - -export function fmtDuration(ms: number) { - const total = Math.max(0, Math.floor(ms / 1000)) - const hours = Math.floor(total / 3600) - const mins = Math.floor((total % 3600) / 60) - const secs = total % 60 - - if (hours > 0) { - return `${hours}h ${mins}m` - } - - if (mins > 0) { - return `${mins}m ${secs}s` - } - - return `${secs}s` -} - -export const stickyPromptFromViewport = ( - messages: readonly Msg[], - offsets: ArrayLike, - top: number, - sticky: boolean -) => { - if (sticky || !messages.length) { - return '' - } - - let lo = 0 - let hi = offsets.length - - while (lo < hi) { - const mid = (lo + hi) >> 1 - - if (offsets[mid]! <= top) { - lo = mid + 1 - } else { - hi = mid - } - } - - const first = Math.max(0, Math.min(messages.length - 1, lo - 1)) - - if (messages[first]?.role === 'user' && (offsets[first] ?? 0) + 1 >= top) { - return '' - } - - for (let i = first - 1; i >= 0; i--) { - if (messages[i]?.role !== 'user') { - continue - } - - if ((offsets[i] ?? 0) + 1 >= top) { - continue - } - - return userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() - } - - return '' -} diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index aa7e28d4dd..b34ee54bea 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -36,7 +36,7 @@ export interface CompletionItem { } export interface GatewayRpc { - (method: string, params?: Record): Promise + (method: string, params?: Record): Promise } export interface GatewayServices { @@ -53,10 +53,10 @@ export interface OverlayState { approval: ApprovalReq | null clarify: ClarifyReq | null modelPicker: boolean - pager: PagerState | null + pager: null | PagerState picker: boolean - secret: SecretReq | null - sudo: SudoReq | null + secret: null | SecretReq + sudo: null | SudoReq } export interface PagerState { @@ -65,11 +65,6 @@ export interface PagerState { title?: string } -export interface ToolCompleteRibbon { - label: string - line: string -} - export interface TranscriptRow { index: number key: string @@ -81,8 +76,8 @@ export interface UiState { busy: boolean compact: boolean detailsMode: DetailsMode - info: SessionInfo | null - sid: string | null + info: null | SessionInfo + sid: null | string status: string statusBar: boolean theme: Theme @@ -112,18 +107,18 @@ export interface ComposerActions { pushHistory: (text: string) => void replaceQueue: (index: number, text: string) => void setCompIdx: StateSetter - setHistoryIdx: StateSetter + setHistoryIdx: StateSetter setInput: StateSetter setInputBuf: StateSetter setPasteSnips: StateSetter - setQueueEdit: (index: number | null) => void + setQueueEdit: (index: null | number) => void syncQueue: () => void } export interface ComposerRefs { historyDraftRef: MutableRefObject historyRef: MutableRefObject - queueEditRef: MutableRefObject + queueEditRef: MutableRefObject queueRef: MutableRefObject submitRef: MutableRefObject<(value: string) => void> } @@ -132,11 +127,11 @@ export interface ComposerState { compIdx: number compReplace: number completions: CompletionItem[] - historyIdx: number | null + historyIdx: null | number input: string inputBuf: string[] pasteSnips: PasteSnippet[] - queueEditIdx: number | null + queueEditIdx: null | number queuedDisplay: string[] } @@ -152,72 +147,6 @@ export interface UseComposerStateResult { state: ComposerState } -export interface InterruptTurnOptions { - appendMessage: (msg: Msg) => void - gw: { request: (method: string, params?: Record) => Promise } - sid: string - sys: (text: string) => void -} - -export interface TurnActions { - clearReasoning: () => void - endReasoningPhase: () => void - idle: () => void - interruptTurn: (options: InterruptTurnOptions) => void - pruneTransient: () => void - pulseReasoningStreaming: () => void - pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void - pushTrail: (line: string) => void - scheduleReasoning: () => void - scheduleStreaming: () => void - setActivity: StateSetter - setReasoning: StateSetter - setReasoningTokens: StateSetter - setReasoningActive: StateSetter - setToolTokens: StateSetter - setReasoningStreaming: StateSetter - setStreaming: StateSetter - setSubagents: StateSetter - setTools: StateSetter - setTurnTrail: StateSetter -} - -export interface TurnRefs { - activeToolsRef: MutableRefObject - bufRef: MutableRefObject - interruptedRef: MutableRefObject - lastStatusNoteRef: MutableRefObject - persistedToolLabelsRef: MutableRefObject> - protocolWarnedRef: MutableRefObject - reasoningRef: MutableRefObject - reasoningStreamingTimerRef: MutableRefObject | null> - reasoningTimerRef: MutableRefObject | null> - statusTimerRef: MutableRefObject | null> - streamTimerRef: MutableRefObject | null> - toolTokenAccRef: MutableRefObject - toolCompleteRibbonRef: MutableRefObject - turnToolsRef: MutableRefObject -} - -export interface TurnState { - activity: ActivityItem[] - reasoning: string - reasoningTokens: number - reasoningActive: boolean - reasoningStreaming: boolean - streaming: string - subagents: SubagentProgress[] - toolTokens: number - tools: ActiveTool[] - turnTrail: string[] -} - -export interface UseTurnStateResult { - actions: TurnActions - refs: TurnRefs - state: TurnState -} - export interface InputHandlerActions { answerClarify: (answer: string) => void appendMessage: (msg: Msg) => void @@ -238,15 +167,11 @@ export interface InputHandlerContext { gateway: GatewayServices terminal: { hasSelection: boolean - scrollRef: RefObject + scrollRef: RefObject scrollWithSelection: (delta: number) => void selection: SelectionApi stdout?: NodeJS.WriteStream } - turn: { - actions: TurnActions - refs: TurnRefs - } voice: { recording: boolean setProcessing: StateSetter @@ -262,7 +187,7 @@ export interface InputHandlerResult { export interface GatewayEventHandlerContext { composer: { dequeue: () => string | undefined - queueEditRef: MutableRefObject + queueEditRef: MutableRefObject sendQueued: (text: string) => void } gateway: GatewayServices @@ -271,7 +196,7 @@ export interface GatewayEventHandlerContext { colsRef: MutableRefObject newSession: (msg?: string) => void resetSession: () => void - setCatalog: StateSetter + setCatalog: StateSetter } system: { bellOnComplete: boolean @@ -282,45 +207,9 @@ export interface GatewayEventHandlerContext { appendMessage: (msg: Msg) => void setHistoryItems: StateSetter } - turn: { - actions: Pick< - TurnActions, - | 'clearReasoning' - | 'endReasoningPhase' - | 'idle' - | 'pruneTransient' - | 'pulseReasoningStreaming' - | 'pushActivity' - | 'pushTrail' - | 'scheduleReasoning' - | 'scheduleStreaming' - | 'setActivity' - | 'setReasoningTokens' - | 'setStreaming' - | 'setSubagents' - | 'setToolTokens' - | 'setTools' - | 'setTurnTrail' - > - refs: Pick< - TurnRefs, - | 'activeToolsRef' - | 'bufRef' - | 'interruptedRef' - | 'lastStatusNoteRef' - | 'persistedToolLabelsRef' - | 'protocolWarnedRef' - | 'reasoningRef' - | 'statusTimerRef' - | 'toolTokenAccRef' - | 'toolCompleteRibbonRef' - | 'turnToolsRef' - > - } } export interface SlashHandlerContext { - slashFlightRef: MutableRefObject composer: { enqueue: (text: string) => void hasSelection: boolean @@ -331,20 +220,21 @@ export interface SlashHandlerContext { } gateway: GatewayServices local: { - catalog: SlashCatalog | null + catalog: null | SlashCatalog getHistoryItems: () => Msg[] getLastUserMsg: () => string - maybeWarn: (value: any) => void + maybeWarn: (value: unknown) => void } session: { - closeSession: (targetSid?: string | null) => Promise + closeSession: (targetSid?: null | string) => Promise die: () => void guardBusySessionSwitch: (what?: string) => boolean newSession: (msg?: string) => void - resetVisibleHistory: (info?: SessionInfo | null) => void + resetVisibleHistory: (info?: null | SessionInfo) => void resumeById: (id: string) => void setSessionStartedAt: StateSetter } + slashFlightRef: MutableRefObject transcript: { page: (text: string, title?: string) => void panel: (title: string, sections: PanelSection[]) => void @@ -377,7 +267,7 @@ export interface AppLayoutComposerProps { input: string inputBuf: string[] pagerPageSize: number - queueEditIdx: number | null + queueEditIdx: null | number queuedDisplay: string[] submit: (value: string) => void updateInput: StateSetter @@ -386,9 +276,9 @@ export interface AppLayoutComposerProps { export interface AppLayoutProgressProps { activity: ActivityItem[] reasoning: string - reasoningTokens: number reasoningActive: boolean reasoningStreaming: boolean + reasoningTokens: number showProgressArea: boolean showStreamingArea: boolean streaming: string @@ -401,7 +291,7 @@ export interface AppLayoutProgressProps { export interface AppLayoutStatusProps { cwdLabel: string goodVibesTick: number - sessionStartedAt: number | null + sessionStartedAt: null | number showStickyPrompt: boolean statusColor: string stickyPrompt: string @@ -410,7 +300,7 @@ export interface AppLayoutStatusProps { export interface AppLayoutTranscriptProps { historyItems: Msg[] - scrollRef: RefObject + scrollRef: RefObject virtualHistory: VirtualHistoryState virtualRows: TranscriptRow[] } diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts new file mode 100644 index 0000000000..4b619bed5a --- /dev/null +++ b/ui-tui/src/app/slash/commands/core.ts @@ -0,0 +1,293 @@ +import { dailyFortune, randomFortune } from '../../../content/fortunes.js' +import { HOTKEYS } from '../../../content/hotkeys.js' +import { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js' +import type { ConfigGetValueResponse, ConfigSetResponse, SessionUndoResponse } from '../../../gatewayTypes.js' +import { writeOsc52Clipboard } from '../../../lib/osc52.js' +import type { DetailsMode, Msg, PanelSection } from '../../../types.js' +import { patchOverlayState } from '../../overlayStore.js' +import { patchUiState } from '../../uiStore.js' +import type { SlashCommand } from '../types.js' + +const flagFromArg = (arg: string, current: boolean): boolean | null => { + const mode = arg.trim().toLowerCase() + + if (!arg) { + return !current + } + + if (mode === 'on') { + return true + } + + if (mode === 'off') { + return false + } + + if (mode === 'toggle') { + return !current + } + + return null +} + +const DETAIL_MODES = new Set(['collapsed', 'cycle', 'expanded', 'hidden', 'toggle']) + +export const coreCommands: SlashCommand[] = [ + { + help: 'list commands + hotkeys', + name: 'help', + run: (_arg, ctx) => { + const sections: PanelSection[] = (ctx.local.catalog?.categories ?? []).map(cat => ({ + rows: cat.pairs, + title: cat.name + })) + + if (ctx.local.catalog?.skillCount) { + sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` }) + } + + sections.push({ + rows: [ + ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], + ['/fortune [random|daily]', 'show a random or daily local fortune'] + ], + title: 'TUI' + }) + sections.push({ rows: HOTKEYS, title: 'Hotkeys' }) + + ctx.transcript.panel('Commands', sections) + } + }, + + { + aliases: ['exit', 'q'], + help: 'exit hermes', + name: 'quit', + run: (_arg, ctx) => ctx.session.die() + }, + + { + aliases: ['new'], + help: 'start a new session', + name: 'clear', + run: (_arg, ctx, cmd) => { + if (ctx.session.guardBusySessionSwitch('switch sessions')) { + return + } + + patchUiState({ status: 'forging session…' }) + ctx.session.newSession(cmd.startsWith('/new') ? 'new session started' : undefined) + } + }, + + { + help: 'resume a prior session', + name: 'resume', + run: (arg, ctx) => { + if (ctx.session.guardBusySessionSwitch('switch sessions')) { + return + } + + arg ? ctx.session.resumeById(arg) : patchOverlayState({ picker: true }) + } + }, + + { + help: 'toggle compact transcript', + name: 'compact', + run: (arg, ctx) => { + const next = flagFromArg(arg, ctx.ui.compact) + + if (next === null) { + return ctx.transcript.sys('usage: /compact [on|off|toggle]') + } + + patchUiState({ compact: next }) + ctx.gateway.rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) + + queueMicrotask(() => ctx.transcript.sys(`compact ${next ? 'on' : 'off'}`)) + } + }, + + { + aliases: ['detail'], + help: 'control agent detail visibility', + name: 'details', + run: (arg, ctx) => { + const { gateway, transcript, ui } = ctx + + if (!arg) { + gateway + .rpc('config.get', { key: 'details_mode' }) + .then(r => { + if (ctx.stale()) { + return + } + + const mode = parseDetailsMode(r?.value) ?? ui.detailsMode + + patchUiState({ detailsMode: mode }) + transcript.sys(`details: ${mode}`) + }) + .catch(() => { + if (!ctx.stale()) { + transcript.sys(`details: ${ui.detailsMode}`) + } + }) + + return + } + + const mode = arg.trim().toLowerCase() + + if (!DETAIL_MODES.has(mode)) { + return transcript.sys('usage: /details [hidden|collapsed|expanded|cycle]') + } + + const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode) + + patchUiState({ detailsMode: next }) + gateway.rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) + transcript.sys(`details: ${next}`) + } + }, + + { + help: 'local fortune', + name: 'fortune', + run: (arg, ctx) => { + const key = arg.trim().toLowerCase() + + if (!arg || key === 'random') { + return ctx.transcript.sys(randomFortune()) + } + + if (['daily', 'stable', 'today'].includes(key)) { + return ctx.transcript.sys(dailyFortune(ctx.sid)) + } + + ctx.transcript.sys('usage: /fortune [random|daily]') + } + }, + + { + help: 'copy selection or assistant message', + name: 'copy', + run: (arg, ctx) => { + const { sys } = ctx.transcript + + if (!arg && ctx.composer.hasSelection && ctx.composer.selection.copySelection()) { + return sys('copied selection') + } + + if (arg && Number.isNaN(parseInt(arg, 10))) { + return sys('usage: /copy [number]') + } + + const all = ctx.local.getHistoryItems().filter(m => m.role === 'assistant') + const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] + + if (!target) { + return sys('nothing to copy') + } + + writeOsc52Clipboard(target.text) + sys('sent OSC52 copy sequence (terminal support required)') + } + }, + + { + help: 'paste clipboard image', + name: 'paste', + run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.paste()) + }, + + { + help: 'view gateway logs', + name: 'logs', + run: (arg, ctx) => { + const text = ctx.gateway.gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) + + text ? ctx.transcript.page(text, 'Logs') : ctx.transcript.sys('no gateway logs') + } + }, + + { + aliases: ['sb'], + help: 'toggle status bar', + name: 'statusbar', + run: (arg, ctx) => { + const next = flagFromArg(arg, ctx.ui.statusBar) + + if (next === null) { + return ctx.transcript.sys('usage: /statusbar [on|off|toggle]') + } + + patchUiState({ statusBar: next }) + ctx.gateway.rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) + + queueMicrotask(() => ctx.transcript.sys(`status bar ${next ? 'on' : 'off'}`)) + } + }, + + { + help: 'inspect or enqueue a message', + name: 'queue', + run: (arg, ctx) => { + if (!arg) { + return ctx.transcript.sys(`${ctx.composer.queueRef.current.length} queued message(s)`) + } + + ctx.composer.enqueue(arg) + ctx.transcript.sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + } + }, + + { + help: 'undo last exchange', + name: 'undo', + run: (_arg, ctx) => { + if (!ctx.sid) { + return ctx.transcript.sys('nothing to undo') + } + + ctx.gateway.rpc('session.undo', { session_id: ctx.sid }).then( + ctx.guarded(r => { + if ((r.removed ?? 0) > 0) { + ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev)) + ctx.transcript.sys(`undid ${r.removed} messages`) + } else { + ctx.transcript.sys('nothing to undo') + } + }) + ) + } + }, + + { + help: 'retry last user message', + name: 'retry', + run: (_arg, ctx) => { + const last = ctx.local.getLastUserMsg() + + if (!last) { + return ctx.transcript.sys('nothing to retry') + } + + if (!ctx.sid) { + return ctx.transcript.send(last) + } + + ctx.gateway.rpc('session.undo', { session_id: ctx.sid }).then( + ctx.guarded(r => { + if ((r.removed ?? 0) <= 0) { + return ctx.transcript.sys('nothing to retry') + } + + ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev)) + ctx.transcript.send(last) + }) + ) + } + } +] diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts new file mode 100644 index 0000000000..3ea300ebed --- /dev/null +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -0,0 +1,368 @@ +import type { + AgentsListResponse, + BrowserManageResponse, + ConfigShowResponse, + CronListResponse, + PluginsListResponse, + RollbackActionResponse, + RollbackListResponse, + SkillsBrowseResponse, + SkillsListResponse, + SlashExecResponse, + ToolsConfigureResponse, + ToolsetsListResponse, + ToolsListResponse, + ToolsShowResponse +} from '../../../gatewayTypes.js' +import type { PanelSection } from '../../../types.js' +import type { SlashCommand, SlashRunCtx } from '../types.js' + +const passthroughSlash = (ctx: SlashRunCtx, cmd: string, fallback: string) => + ctx.gateway.gw + .request('slash.exec', { command: cmd.slice(1), session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + ctx.transcript.sys(r?.warning ? `warning: ${r.warning}\n${r?.output || fallback}` : r?.output || fallback) + }) + .catch(ctx.guardedErr) + +const clip = (s: string, max: number) => (s.length > max ? `${s.slice(0, max)}…` : s) + +export const opsCommands: SlashCommand[] = [ + { + help: 'list or restore checkpoints', + name: 'rollback', + run: (arg, ctx) => { + const [sub, ...rest] = (arg || 'list').split(/\s+/) + + if (!sub || sub === 'list') { + return ctx.gateway.rpc('rollback.list', { session_id: ctx.sid }).then( + ctx.guarded(r => { + if (!r.checkpoints?.length) { + return ctx.transcript.sys('no checkpoints') + } + + ctx.transcript.panel('Checkpoints', [ + { + rows: r.checkpoints.map( + (c, i) => [`${i + 1} ${c.hash?.slice(0, 8) ?? ''}`, c.message ?? ''] as [string, string] + ) + } + ]) + }) + ) + } + + const isRestoreOrDiff = sub === 'restore' || sub === 'diff' + const hash = isRestoreOrDiff ? rest[0] : sub + const filePath = (isRestoreOrDiff ? rest.slice(1) : rest).join(' ').trim() + const method = sub === 'diff' ? 'rollback.diff' : 'rollback.restore' + + ctx.gateway + .rpc(method, { + hash, + session_id: ctx.sid, + ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) + }) + .then(ctx.guarded(r => ctx.transcript.sys(r.rendered || r.diff || r.message || 'done'))) + } + }, + + { + help: 'manage browser connection', + name: 'browser', + run: (arg, ctx) => { + const [action, url] = (arg || 'status').split(/\s+/) + + ctx.gateway + .rpc('browser.manage', { action, ...(url ? { url } : {}) }) + .then( + ctx.guarded(r => + ctx.transcript.sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') + ) + ) + } + }, + + { + help: 'list installed plugins', + name: 'plugins', + run: (_arg, ctx) => { + ctx.gateway.rpc('plugins.list', {}).then( + ctx.guarded(r => { + if (!r.plugins?.length) { + return ctx.transcript.sys('no plugins') + } + + ctx.transcript.panel('Plugins', [ + { items: r.plugins.map(p => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) } + ]) + }) + ) + } + }, + + { + help: 'list or browse skills', + name: 'skills', + run: (arg, ctx, cmd) => { + const [sub, ...rest] = (arg || '').split(/\s+/).filter(Boolean) + + if (!sub || sub === 'list') { + return ctx.gateway.rpc('skills.manage', { action: 'list' }).then( + ctx.guarded(r => { + if (!r.skills || !Object.keys(r.skills).length) { + return ctx.transcript.sys('no skills installed') + } + + ctx.transcript.panel( + 'Installed Skills', + Object.entries(r.skills).map(([title, items]) => ({ items, title })) + ) + }) + ) + } + + if (sub === 'browse') { + const pageNumber = parseInt(rest[0] ?? '1', 10) || 1 + + return ctx.gateway.rpc('skills.manage', { action: 'browse', page: pageNumber }).then( + ctx.guarded(r => { + if (!r.items?.length) { + return ctx.transcript.sys('no skills found in the hub') + } + + const page = r.page ?? 1 + const totalPages = r.total_pages ?? 1 + + const sections: PanelSection[] = [ + { + rows: r.items.map(s => [s.name ?? '', clip(s.description ?? '', 60)] as [string, string]) + } + ] + + if (page < totalPages) { + sections.push({ text: `/skills browse ${page + 1} → next page` }) + } + + if (page > 1) { + sections.push({ text: `/skills browse ${page - 1} → prev page` }) + } + + ctx.transcript.panel(`Skills Hub (page ${page}/${totalPages}, ${r.total ?? 0} total)`, sections) + }) + ) + } + + passthroughSlash(ctx, cmd, '/skills: no output') + } + }, + + { + aliases: ['tasks'], + help: 'running agents', + name: 'agents', + run: (_arg, ctx) => { + ctx.gateway + .rpc('agents.list', {}) + .then( + ctx.guarded(r => { + const processes = r.processes ?? [] + const running = processes.filter(p => p.status === 'running') + const finished = processes.filter(p => p.status !== 'running') + const sections: PanelSection[] = [] + + if (running.length) { + sections.push({ + rows: running.map(p => [p.session_id.slice(0, 8), p.command ?? '']), + title: `Running (${running.length})` + }) + } + + if (finished.length) { + sections.push({ + rows: finished.map(p => [p.session_id.slice(0, 8), p.command ?? '']), + title: `Finished (${finished.length})` + }) + } + + if (!sections.length) { + sections.push({ text: 'No active processes' }) + } + + ctx.transcript.panel('Agents', sections) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'list or manage cron jobs', + name: 'cron', + run: (arg, ctx, cmd) => { + if (arg && arg !== 'list') { + return passthroughSlash(ctx, cmd, '(no output)') + } + + ctx.gateway + .rpc('cron.manage', { action: 'list' }) + .then( + ctx.guarded(r => { + const jobs = r.jobs ?? [] + + if (!jobs.length) { + return ctx.transcript.sys('no scheduled jobs') + } + + ctx.transcript.panel('Cron', [ + { + rows: jobs.map( + j => + [j.name || j.job_id?.slice(0, 12) || '', `${j.schedule ?? ''} · ${j.state ?? 'active'}`] as [ + string, + string + ] + ) + } + ]) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'show configuration', + name: 'config', + run: (_arg, ctx) => { + ctx.gateway + .rpc('config.show', {}) + .then( + ctx.guarded(r => + ctx.transcript.panel( + 'Config', + (r.sections ?? []).map(s => ({ rows: s.rows, title: s.title })) + ) + ) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'list, enable, disable tools', + name: 'tools', + run: (arg, ctx) => { + const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) + + if (!subcommand) { + return ctx.gateway + .rpc('tools.show', { session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + if (!r?.sections?.length) { + return ctx.transcript.sys('no tools') + } + + ctx.transcript.panel( + `Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`, + r.sections.map(section => ({ + rows: section.tools.map(tool => [tool.name, tool.description] as [string, string]), + title: section.name + })) + ) + }) + .catch(ctx.guardedErr) + } + + if (subcommand === 'list') { + return ctx.gateway + .rpc('tools.list', { session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + if (!r?.toolsets?.length) { + return ctx.transcript.sys('no tools') + } + + ctx.transcript.panel( + 'Tools', + r.toolsets.map(ts => ({ + items: ts.tools, + title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]` + })) + ) + }) + .catch(ctx.guardedErr) + } + + if (subcommand === 'disable' || subcommand === 'enable') { + if (!names.length) { + ctx.transcript.sys(`usage: /tools ${subcommand} [name ...]`) + ctx.transcript.sys(`built-in toolset: /tools ${subcommand} web`) + ctx.transcript.sys(`MCP tool: /tools ${subcommand} github:create_issue`) + + return + } + + return ctx.gateway + .rpc('tools.configure', { action: subcommand, names, session_id: ctx.sid }) + .then( + ctx.guarded(r => { + if (r.info) { + ctx.session.setSessionStartedAt(Date.now()) + ctx.session.resetVisibleHistory(r.info) + } + + r.changed?.length && + ctx.transcript.sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) + r.unknown?.length && ctx.transcript.sys(`unknown toolsets: ${r.unknown.join(', ')}`) + r.missing_servers?.length && ctx.transcript.sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) + r.reset && ctx.transcript.sys('session reset. new tool configuration is active.') + }) + ) + .catch(ctx.guardedErr) + } + + ctx.transcript.sys('usage: /tools [list|disable|enable] ...') + } + }, + + { + help: 'list toolsets', + name: 'toolsets', + run: (_arg, ctx) => { + ctx.gateway + .rpc('toolsets.list', { session_id: ctx.sid }) + .then( + ctx.guarded(r => { + if (!r.toolsets?.length) { + return ctx.transcript.sys('no toolsets') + } + + ctx.transcript.panel('Toolsets', [ + { + rows: r.toolsets.map( + ts => + [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [ + string, + string + ] + ) + } + ]) + }) + ) + .catch(ctx.guardedErr) + } + } +] diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts new file mode 100644 index 0000000000..c8dfa587a9 --- /dev/null +++ b/ui-tui/src/app/slash/commands/session.ts @@ -0,0 +1,462 @@ +import { imageTokenMeta, introMsg, toTranscriptMessages } from '../../../domain/messages.js' +import type { + BackgroundStartResponse, + BtwStartResponse, + ConfigGetValueResponse, + ConfigSetResponse, + ImageAttachResponse, + InsightsResponse, + ReloadMcpResponse, + SessionBranchResponse, + SessionCompressResponse, + SessionHistoryResponse, + SessionSaveResponse, + SessionTitleResponse, + SessionUsageResponse, + SlashExecResponse, + VoiceToggleResponse +} from '../../../gatewayTypes.js' +import { fmtK } from '../../../lib/text.js' +import type { PanelSection } from '../../../types.js' +import { patchOverlayState } from '../../overlayStore.js' +import { patchUiState } from '../../uiStore.js' +import type { SlashCommand } from '../types.js' + +const PAGE_TITLES: Record = { + debug: 'Debug', + fast: 'Fast', + platforms: 'Platforms', + snapshot: 'Snapshot' +} + +const passthrough = (name: string): SlashCommand => ({ + name, + run: (_arg, ctx, cmd) => + ctx.shared.showSlashOutput({ + command: cmd.slice(1), + flight: ctx.flight, + sid: ctx.sid, + title: PAGE_TITLES[name] ?? name + }) +}) + +const historyLabel = (role: string) => (role === 'assistant' ? 'Hermes' : role === 'user' ? 'You' : 'System') + +export const sessionCommands: SlashCommand[] = [ + passthrough('debug'), + passthrough('fast'), + passthrough('platforms'), + passthrough('snapshot'), + + { + aliases: ['bg'], + help: 'launch a background prompt', + name: 'background', + run: (arg, ctx) => { + if (!arg) { + return ctx.transcript.sys('/background ') + } + + ctx.gateway.rpc('prompt.background', { session_id: ctx.sid, text: arg }).then( + ctx.guarded(r => { + if (!r.task_id) { + return + } + + patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id!) })) + ctx.transcript.sys(`bg ${r.task_id} started`) + }) + ) + } + }, + + { + help: 'by-the-way follow-up', + name: 'btw', + run: (arg, ctx) => { + if (!arg) { + return ctx.transcript.sys('/btw ') + } + + ctx.gateway.rpc('prompt.btw', { session_id: ctx.sid, text: arg }).then( + ctx.guarded(() => { + patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') })) + ctx.transcript.sys('btw running…') + }) + ) + } + }, + + { + help: 'change or show model', + name: 'model', + run: (arg, ctx) => { + if (ctx.session.guardBusySessionSwitch('change models')) { + return + } + + if (!arg) { + return patchOverlayState({ modelPicker: true }) + } + + ctx.gateway.rpc('config.set', { key: 'model', session_id: ctx.sid, value: arg.trim() }).then( + ctx.guarded(r => { + if (!r.value) { + return ctx.transcript.sys('error: invalid response: model switch') + } + + ctx.transcript.sys(`model → ${r.value}`) + ctx.local.maybeWarn(r) + + patchUiState(state => ({ + ...state, + info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} } + })) + }) + ) + } + }, + + { + help: 'attach an image', + name: 'image', + run: (arg, ctx) => { + ctx.gateway.rpc('image.attach', { path: arg, session_id: ctx.sid }).then( + ctx.guarded(r => { + const meta = imageTokenMeta(r) + + ctx.transcript.sys(`attached image: ${r.name ?? ''}${meta ? ` · ${meta}` : ''}`) + r.remainder && ctx.composer.setInput(r.remainder) + }) + ) + } + }, + + { + help: 'show provider details', + name: 'provider', + run: (_arg, ctx) => { + ctx.gateway.gw + .request('slash.exec', { command: 'provider', session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + ctx.transcript.page( + r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', + 'Provider' + ) + }) + .catch(ctx.guardedErr) + } + }, + + { + help: 'switch theme skin', + name: 'skin', + run: (arg, ctx) => { + if (arg) { + return ctx.gateway + .rpc('config.set', { key: 'skin', value: arg }) + .then(ctx.guarded(r => r.value && ctx.transcript.sys(`skin → ${r.value}`))) + } + + ctx.gateway + .rpc('config.get', { key: 'skin' }) + .then(ctx.guarded(r => ctx.transcript.sys(`skin: ${r.value || 'default'}`))) + } + }, + + { + help: 'toggle yolo mode', + name: 'yolo', + run: (_arg, ctx) => { + ctx.gateway + .rpc('config.set', { key: 'yolo', session_id: ctx.sid }) + .then(ctx.guarded(r => ctx.transcript.sys(`yolo ${r.value === '1' ? 'on' : 'off'}`))) + } + }, + + { + help: 'inspect or set reasoning mode', + name: 'reasoning', + run: (arg, ctx) => { + if (!arg) { + return ctx.gateway + .rpc('config.get', { key: 'reasoning' }) + .then( + ctx.guarded( + r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) + ) + ) + } + + ctx.gateway + .rpc('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg }) + .then(ctx.guarded(r => r.value && ctx.transcript.sys(`reasoning: ${r.value}`))) + } + }, + + { + help: 'cycle verbose output', + name: 'verbose', + run: (arg, ctx) => { + ctx.gateway + .rpc('config.set', { key: 'verbose', session_id: ctx.sid, value: arg || 'cycle' }) + .then(ctx.guarded(r => r.value && ctx.transcript.sys(`verbose: ${r.value}`))) + } + }, + + { + help: 'personality panel or switch', + name: 'personality', + run: (arg, ctx) => { + if (arg) { + return ctx.gateway + .rpc('config.set', { key: 'personality', session_id: ctx.sid, value: arg }) + .then( + ctx.guarded(r => { + r.history_reset && ctx.session.resetVisibleHistory(r.info ?? null) + ctx.transcript.sys( + `personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}` + ) + ctx.local.maybeWarn(r) + }) + ) + } + + ctx.gateway.gw + .request('slash.exec', { command: 'personality', session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + ctx.transcript.panel('Personality', [ + { + text: r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)' + } + ]) + }) + .catch(ctx.guardedErr) + } + }, + + { + help: 'compress transcript', + name: 'compress', + run: (arg, ctx) => { + ctx.gateway + .rpc('session.compress', { + session_id: ctx.sid, + ...(arg ? { focus_topic: arg } : {}) + }) + .then( + ctx.guarded(r => { + if (Array.isArray(r.messages)) { + const rows = toTranscriptMessages(r.messages) + + ctx.transcript.setHistoryItems(r.info ? [introMsg(r.info), ...rows] : rows) + } + + r.info && patchUiState({ info: r.info }) + r.usage && patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } })) + + if ((r.removed ?? 0) <= 0) { + return ctx.transcript.sys('nothing to compress') + } + + ctx.transcript.sys( + `compressed ${r.removed} messages${r.usage?.total ? ` · ${fmtK(r.usage.total)} tok` : ''}` + ) + }) + ) + } + }, + + { + help: 'stop background processes', + name: 'stop', + run: (_arg, ctx) => { + ctx.gateway + .rpc<{ killed?: number }>('process.stop', {}) + .then( + ctx.guarded<{ killed?: number }>(r => ctx.transcript.sys(`killed ${r.killed ?? 0} registered process(es)`)) + ) + } + }, + + { + aliases: ['fork'], + help: 'branch the session', + name: 'branch', + run: (arg, ctx) => { + const prevSid = ctx.sid + + ctx.gateway.rpc('session.branch', { name: arg, session_id: ctx.sid }).then( + ctx.guarded(r => { + if (!r.session_id) { + return + } + + void ctx.session.closeSession(prevSid) + patchUiState({ sid: r.session_id }) + ctx.session.setSessionStartedAt(Date.now()) + ctx.transcript.setHistoryItems([]) + ctx.transcript.sys(`branched → ${r.title ?? ''}`) + }) + ) + } + }, + + { + aliases: ['reload_mcp'], + help: 'reload MCP servers', + name: 'reload-mcp', + run: (_arg, ctx) => + ctx.gateway + .rpc('reload.mcp', { session_id: ctx.sid }) + .then(ctx.guarded(() => ctx.transcript.sys('MCP reloaded'))) + }, + + { + help: 'inspect or set session title', + name: 'title', + run: (arg, ctx) => { + ctx.gateway + .rpc('session.title', { session_id: ctx.sid, ...(arg ? { title: arg } : {}) }) + .then(ctx.guarded(r => ctx.transcript.sys(`title: ${r.title || '(none)'}`))) + } + }, + + { + help: 'session usage', + name: 'usage', + run: (_arg, ctx) => { + ctx.gateway.rpc('session.usage', { session_id: ctx.sid }).then(r => { + if (ctx.stale()) { + return + } + + if (r) { + patchUiState({ + usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 } + }) + } + + if (!r?.calls) { + return ctx.transcript.sys('no API calls yet') + } + + const f = (v: number | undefined) => (v ?? 0).toLocaleString() + const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null + + const rows: [string, string][] = [ + ['Model', r.model ?? ''], + ['Input tokens', f(r.input)], + ['Cache read tokens', f(r.cache_read)], + ['Cache write tokens', f(r.cache_write)], + ['Output tokens', f(r.output)], + ['Total tokens', f(r.total)], + ['API calls', f(r.calls)] + ] + + const sections: PanelSection[] = [{ rows }] + + cost && rows.push(['Cost', cost]) + r.context_max && + sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) + r.compressions && sections.push({ text: `Compressions: ${r.compressions}` }) + + ctx.transcript.panel('Usage', sections) + }) + } + }, + + { + help: 'save transcript to disk', + name: 'save', + run: (_arg, ctx) => { + ctx.gateway + .rpc('session.save', { session_id: ctx.sid }) + .then(ctx.guarded(r => r.file && ctx.transcript.sys(`saved: ${r.file}`))) + } + }, + + { + help: 'view message history', + name: 'history', + run: (_arg, ctx) => { + ctx.gateway.rpc('session.history', { session_id: ctx.sid }).then(r => { + if (ctx.stale() || typeof r?.count !== 'number') { + return + } + + if (!r.messages?.length) { + return ctx.transcript.sys(`${r.count} messages`) + } + + const body = r.messages + .map((m, i) => + m.role === 'tool' + ? `[Tool #${i + 1}] ${m.name || 'tool'} ${m.context || ''}`.trim() + : `[${historyLabel(m.role)} #${i + 1}] ${m.text || ''}`.trim() + ) + .join('\n\n') + + ctx.transcript.page(body, `History (${r.count})`) + }) + } + }, + + { + help: 'show current profile', + name: 'profile', + run: (_arg, ctx) => { + ctx.gateway.rpc('config.get', { key: 'profile' }).then( + ctx.guarded(r => { + const text = r.display || r.home || '(unknown profile)' + const lines = text.split('\n').filter(Boolean) + + lines.length <= 2 ? ctx.transcript.panel('Profile', [{ text }]) : ctx.transcript.page(text, 'Profile') + }) + ) + } + }, + + { + help: 'toggle voice input', + name: 'voice', + run: (arg, ctx) => { + const action = arg === 'on' || arg === 'off' ? arg : 'status' + + ctx.gateway.rpc('voice.toggle', { action }).then( + ctx.guarded(r => { + ctx.voice.setVoiceEnabled(!!r.enabled) + ctx.transcript.sys(`voice: ${r.enabled ? 'on' : 'off'}`) + }) + ) + } + }, + + { + help: 'view usage insights', + name: 'insights', + run: (arg, ctx) => { + ctx.gateway.rpc('insights.get', { days: parseInt(arg) || 30 }).then( + ctx.guarded(r => + ctx.transcript.panel('Insights', [ + { + rows: [ + ['Period', `${r.days ?? 0} days`], + ['Sessions', `${r.sessions ?? 0}`], + ['Messages', `${r.messages ?? 0}`] + ] + } + ]) + ) + ) + } + } +] diff --git a/ui-tui/src/app/slash/createSlashCoreHandler.ts b/ui-tui/src/app/slash/createSlashCoreHandler.ts deleted file mode 100644 index 7cb893c4c9..0000000000 --- a/ui-tui/src/app/slash/createSlashCoreHandler.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { HOTKEYS } from '../../constants.js' -import { writeOsc52Clipboard } from '../../lib/osc52.js' -import type { DetailsMode, PanelSection } from '../../types.js' -import { nextDetailsMode, parseDetailsMode } from '../helpers.js' -import type { SlashHandlerContext } from '../interfaces.js' -import { patchOverlayState } from '../overlayStore.js' -import { patchUiState } from '../uiStore.js' - -import { isStaleSlash } from './isStaleSlash.js' - -const FORTUNES = [ - 'you are one clean refactor away from clarity', - 'a tiny rename today prevents a huge bug tomorrow', - 'your next commit message will be immaculate', - 'the edge case you are ignoring is already solved in your head', - 'minimal diff, maximal calm', - 'today favors bold deletions over new abstractions', - 'the right helper is already in your codebase', - 'you will ship before overthinking catches up', - 'tests are about to save your future self', - 'your instincts are correctly suspicious of that one branch' -] - -const LEGENDARY_FORTUNES = [ - 'legendary drop: one-line fix, first try', - 'legendary drop: every flaky test passes cleanly', - 'legendary drop: your diff teaches by itself' -] - -const hash = (input: string) => { - let out = 2166136261 - - for (let i = 0; i < input.length; i++) { - out ^= input.charCodeAt(i) - out = Math.imul(out, 16777619) - } - - return out >>> 0 -} - -const fortuneFromScore = (score: number) => { - const rare = score % 20 === 0 - const bag = rare ? LEGENDARY_FORTUNES : FORTUNES - - return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}` -} - -const randomFortune = () => fortuneFromScore(Math.floor(Math.random() * 0x7fffffff)) - -const dailyFortune = (sid: null | string) => fortuneFromScore(hash(`${sid || 'anon'}|${new Date().toDateString()}`)) - -export function createSlashCoreHandler(ctx: SlashHandlerContext) { - const { enqueue, hasSelection, paste, queueRef, selection } = ctx.composer - const { catalog, getHistoryItems, getLastUserMsg } = ctx.local - const { guardBusySessionSwitch, newSession, resumeById } = ctx.session - const { panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript - - return ({ arg, flight, name, sid, ui }: SlashCommand) => { - switch (name) { - case 'help': { - const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }: any) => ({ - title: catName, - rows: pairs - })) - - if (catalog?.skillCount) { - sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` }) - } - - sections.push({ - title: 'TUI', - rows: [ - ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], - ['/fortune [random|daily]', 'show a random or daily local fortune'] - ] - }) - sections.push({ title: 'Hotkeys', rows: HOTKEYS }) - panel('Commands', sections) - - return true - } - - case 'quit': - - case 'exit': - - case 'q': - ctx.session.die() - - return true - - case 'clear': - - case 'new': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - patchUiState({ status: 'forging session…' }) - newSession(name === 'new' ? 'new session started' : undefined) - - return true - - case 'resume': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - arg ? resumeById(arg) : patchOverlayState({ picker: true }) - - return true - case 'compact': { - const mode = arg.trim().toLowerCase() - - if (arg && !['on', 'off', 'toggle'].includes(mode)) { - sys('usage: /compact [on|off|toggle]') - - return true - } - - const next = mode === 'on' ? true : mode === 'off' ? false : !ui.compact - - patchUiState({ compact: next }) - ctx.gateway.rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) - queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`)) - - return true - } - - case 'details': - - case 'detail': - if (!arg) { - ctx.gateway - .rpc('config.get', { key: 'details_mode' }) - .then((r: any) => { - if (isStaleSlash(ctx, flight, sid)) { - return - } - - const mode = parseDetailsMode(r?.value) ?? ui.detailsMode - - patchUiState({ detailsMode: mode }) - sys(`details: ${mode}`) - }) - .catch(() => { - if (isStaleSlash(ctx, flight, sid)) { - return - } - - sys(`details: ${ui.detailsMode}`) - }) - - return true - } - - { - const mode = arg.trim().toLowerCase() - - if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) { - sys('usage: /details [hidden|collapsed|expanded|cycle]') - - return true - } - - const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode) - - patchUiState({ detailsMode: next }) - ctx.gateway.rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) - sys(`details: ${next}`) - } - - return true - - case 'fortune': - if (!arg || arg.trim().toLowerCase() === 'random') { - sys(randomFortune()) - - return true - } - - if (['daily', 'today', 'stable'].includes(arg.trim().toLowerCase())) { - sys(dailyFortune(sid)) - - return true - } - - sys('usage: /fortune [random|daily]') - - return true - case 'copy': { - if (!arg && hasSelection) { - const copied = selection.copySelection() - - if (copied) { - sys('copied selection') - - return true - } - } - - if (arg && Number.isNaN(parseInt(arg, 10))) { - sys('usage: /copy [number]') - - return true - } - - const all = getHistoryItems().filter((m: any) => m.role === 'assistant') - const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] - - if (!target) { - sys('nothing to copy') - - return true - } - - writeOsc52Clipboard(target.text) - sys('sent OSC52 copy sequence (terminal support required)') - - return true - } - - case 'paste': - if (!arg) { - paste() - - return true - } - - sys('usage: /paste') - - return true - case 'logs': { - const logText = ctx.gateway.gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) - - logText ? ctx.transcript.page(logText, 'Logs') : sys('no gateway logs') - - return true - } - - case 'statusbar': - case 'sb': { - const mode = arg.trim().toLowerCase() - - if (arg && !['on', 'off', 'toggle'].includes(mode)) { - sys('usage: /statusbar [on|off|toggle]') - - return true - } - - const next = mode === 'on' ? true : mode === 'off' ? false : !ui.statusBar - - patchUiState({ statusBar: next }) - ctx.gateway.rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) - queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`)) - - return true - } - - case 'queue': - if (!arg) { - sys(`${queueRef.current.length} queued message(s)`) - - return true - } - - enqueue(arg) - sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) - - return true - - case 'undo': - if (!sid) { - sys('nothing to undo') - - return true - } - - ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => { - if (isStaleSlash(ctx, flight, sid) || !r) { - return - } - - if (r.removed > 0) { - setHistoryItems((prev: any[]) => trimLastExchange(prev)) - sys(`undid ${r.removed} messages`) - } else { - sys('nothing to undo') - } - }) - - return true - case 'retry': { - const lastUserMsg = getLastUserMsg() - - if (!lastUserMsg) { - sys('nothing to retry') - - return true - } - - if (!sid) { - send(lastUserMsg) - - return true - } - - ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => { - if (isStaleSlash(ctx, flight, sid) || !r) { - return - } - - if (r.removed <= 0) { - sys('nothing to retry') - - return - } - - setHistoryItems((prev: any[]) => trimLastExchange(prev)) - send(lastUserMsg) - }) - - return true - } - } - - return false - } -} - -interface SlashCommand { - arg: string - flight: number - name: string - sid: null | string - ui: { - compact: boolean - detailsMode: DetailsMode - statusBar: boolean - } -} diff --git a/ui-tui/src/app/slash/createSlashOpsHandler.ts b/ui-tui/src/app/slash/createSlashOpsHandler.ts deleted file mode 100644 index 9b8a277be5..0000000000 --- a/ui-tui/src/app/slash/createSlashOpsHandler.ts +++ /dev/null @@ -1,456 +0,0 @@ -import type { ToolsConfigureResponse, ToolsListResponse, ToolsShowResponse } from '../../gatewayTypes.js' -import { rpcErrorMessage } from '../../lib/rpc.js' -import type { PanelSection } from '../../types.js' -import type { SlashHandlerContext } from '../interfaces.js' - -import { isStaleSlash } from './isStaleSlash.js' -import type { ParsedSlashCommand } from './shared.js' - -export function createSlashOpsHandler(ctx: SlashHandlerContext) { - const { rpc } = ctx.gateway - const { resetVisibleHistory, setSessionStartedAt } = ctx.session - const { panel, sys } = ctx.transcript - - return ({ arg, cmd, flight, name, sid }: OpsSlashCommand) => { - const stale = () => isStaleSlash(ctx, flight, sid) - - switch (name) { - case 'rollback': { - const [sub, ...rest] = (arg || 'list').split(/\s+/) - - if (!sub || sub === 'list') { - rpc('rollback.list', { session_id: sid }).then((r: any) => { - if (stale() || !r) { - return - } - - if (!r.checkpoints?.length) { - sys('no checkpoints') - - return - } - - panel('Checkpoints', [ - { - rows: r.checkpoints.map( - (c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string] - ) - } - ]) - }) - - return true - } - - const hash = sub === 'restore' || sub === 'diff' ? rest[0] : sub - const filePath = (sub === 'restore' || sub === 'diff' ? rest.slice(1) : rest).join(' ').trim() - - rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { - session_id: sid, - hash, - ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) - }).then((r: any) => { - if (stale() || !r) { - return - } - - sys(r.rendered || r.diff || r.message || 'done') - }) - - return true - } - - case 'browser': { - const [action, ...rest] = (arg || 'status').split(/\s+/) - - rpc('browser.manage', { action, ...(rest[0] ? { url: rest[0] } : {}) }).then((r: any) => { - if (stale() || !r) { - return - } - - sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') - }) - - return true - } - - case 'plugins': - rpc('plugins.list', {}).then((r: any) => { - if (stale() || !r) { - return - } - - if (!r.plugins?.length) { - sys('no plugins') - - return - } - - panel('Plugins', [ - { - items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) - } - ]) - }) - - return true - case 'skills': { - const [sub, ...rest] = (arg || '').split(/\s+/).filter(Boolean) - - if (!sub || sub === 'list') { - rpc('skills.manage', { action: 'list' }).then((r: any) => { - if (stale() || !r) { - return - } - - const skills = r.skills as Record | undefined - - if (!skills || !Object.keys(skills).length) { - sys('no skills installed') - - return - } - - panel( - 'Installed Skills', - Object.entries(skills).map(([title, items]) => ({ items, title })) - ) - }) - - return true - } - - if (sub === 'browse') { - const pageNumber = parseInt(rest[0] ?? '1', 10) || 1 - - rpc('skills.manage', { action: 'browse', page: pageNumber }).then((r: any) => { - if (stale() || !r) { - return - } - - if (!r.items?.length) { - sys('no skills found in the hub') - - return - } - - const sections: PanelSection[] = [ - { - rows: r.items.map( - (s: any) => - [s.name ?? '', (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')] as [ - string, - string - ] - ) - } - ] - - if (r.page < r.total_pages) { - sections.push({ text: `/skills browse ${r.page + 1} → next page` }) - } - - if (r.page > 1) { - sections.push({ text: `/skills browse ${r.page - 1} → prev page` }) - } - - panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections) - }) - - return true - } - - ctx.gateway.gw - .request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - if (stale()) { - return - } - - sys( - r?.warning - ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}` - : r?.output || '/skills: no output' - ) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - } - - case 'agents': - - case 'tasks': - rpc('agents.list', {}) - .then((r: any) => { - if (stale() || !r) { - return - } - - const processes = r.processes ?? [] - const running = processes.filter((p: any) => p.status === 'running') - const finished = processes.filter((p: any) => p.status !== 'running') - const sections: PanelSection[] = [] - - running.length && - sections.push({ - title: `Running (${running.length})`, - rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - finished.length && - sections.push({ - title: `Finished (${finished.length})`, - rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - !sections.length && sections.push({ text: 'No active processes' }) - panel('Agents', sections) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - - case 'cron': - if (!arg || arg === 'list') { - rpc('cron.manage', { action: 'list' }) - .then((r: any) => { - if (stale() || !r) { - return - } - - const jobs = r.jobs ?? [] - - if (!jobs.length) { - sys('no scheduled jobs') - - return - } - - panel('Cron', [ - { - rows: jobs.map( - (j: any) => - [j.name || j.job_id?.slice(0, 12), `${j.schedule} · ${j.state ?? 'active'}`] as [string, string] - ) - } - ]) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - } else { - ctx.gateway.gw - .request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - if (stale()) { - return - } - - sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)') - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - } - - return true - - case 'config': - rpc('config.show', {}) - .then((r: any) => { - if (stale() || !r) { - return - } - - panel( - 'Config', - (r.sections ?? []).map((s: any) => ({ - title: s.title, - rows: s.rows - })) - ) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - case 'tools': { - const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) - - if (!subcommand) { - rpc('tools.show', { session_id: sid }) - .then(r => { - if (stale()) { - return - } - - if (!r?.sections?.length) { - sys('no tools') - - return - } - - panel( - `Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`, - r.sections.map(section => ({ - title: section.name, - rows: section.tools.map(tool => [tool.name, tool.description] as [string, string]) - })) - ) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - } - - if (subcommand === 'list') { - rpc('tools.list', { session_id: sid }) - .then(r => { - if (stale()) { - return - } - - if (!r?.toolsets?.length) { - sys('no tools') - - return - } - - panel( - 'Tools', - r.toolsets.map(ts => ({ - title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, - items: ts.tools - })) - ) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - } - - if (subcommand === 'disable' || subcommand === 'enable') { - if (!names.length) { - sys(`usage: /tools ${subcommand} [name ...]`) - sys(`built-in toolset: /tools ${subcommand} web`) - sys(`MCP tool: /tools ${subcommand} github:create_issue`) - - return true - } - - rpc('tools.configure', { - action: subcommand, - names, - session_id: sid - }) - .then(r => { - if (stale() || !r) { - return - } - - if (r.info) { - setSessionStartedAt(Date.now()) - resetVisibleHistory(r.info) - } - - r.changed?.length && sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) - r.unknown?.length && sys(`unknown toolsets: ${r.unknown.join(', ')}`) - r.missing_servers?.length && sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) - r.reset && sys('session reset. new tool configuration is active.') - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - } - - sys('usage: /tools [list|disable|enable] ...') - - return true - } - - case 'toolsets': - rpc('toolsets.list', { session_id: sid }) - .then((r: any) => { - if (stale() || !r) { - return - } - - if (!r.toolsets?.length) { - sys('no toolsets') - - return - } - - panel('Toolsets', [ - { - rows: r.toolsets.map( - (ts: any) => - [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [ - string, - string - ] - ) - } - ]) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - } - - return false - } -} - -interface OpsSlashCommand extends ParsedSlashCommand { - flight: number - sid: null | string -} diff --git a/ui-tui/src/app/slash/createSlashSessionHandler.ts b/ui-tui/src/app/slash/createSlashSessionHandler.ts deleted file mode 100644 index d4c1e404fd..0000000000 --- a/ui-tui/src/app/slash/createSlashSessionHandler.ts +++ /dev/null @@ -1,464 +0,0 @@ -import type { BackgroundStartResponse, SessionHistoryResponse } from '../../gatewayTypes.js' -import { rpcErrorMessage } from '../../lib/rpc.js' -import { fmtK } from '../../lib/text.js' -import type { PanelSection } from '../../types.js' -import { imageTokenMeta, introMsg, toTranscriptMessages } from '../helpers.js' -import type { SlashHandlerContext } from '../interfaces.js' -import { patchOverlayState } from '../overlayStore.js' -import { patchUiState } from '../uiStore.js' - -import { isStaleSlash } from './isStaleSlash.js' -import type { ParsedSlashCommand, SlashShared } from './shared.js' - -const SLASH_OUTPUT_PAGE: Record = { - debug: 'Debug', - fast: 'Fast', - platforms: 'Platforms', - snapshot: 'Snapshot' -} - -export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: SlashShared) { - const { setInput } = ctx.composer - const { gw, rpc } = ctx.gateway - const { maybeWarn } = ctx.local - const { closeSession, guardBusySessionSwitch, resetVisibleHistory, setSessionStartedAt } = ctx.session - const { page, panel, setHistoryItems, sys } = ctx.transcript - const { setVoiceEnabled } = ctx.voice - - return ({ arg, cmd, flight, name, sid }: SessionSlashCommand) => { - const stale = () => isStaleSlash(ctx, flight, sid) - const pageTitle = SLASH_OUTPUT_PAGE[name] - - if (pageTitle) { - shared.showSlashOutput({ command: cmd.slice(1), flight, sid, title: pageTitle }) - - return true - } - - switch (name) { - case 'background': - - case 'bg': - if (!arg) { - sys('/background ') - - return true - } - - rpc('prompt.background', { session_id: sid, text: arg }).then(r => { - if (stale()) { - return - } - - const taskId = r?.task_id - - if (!taskId) { - return - } - - patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(taskId) })) - sys(`bg ${taskId} started`) - }) - - return true - - case 'btw': - if (!arg) { - sys('/btw ') - - return true - } - - rpc('prompt.btw', { session_id: sid, text: arg }).then((r: any) => { - if (stale() || !r) { - return - } - - patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') })) - sys('btw running…') - }) - - return true - - case 'model': - if (guardBusySessionSwitch('change models')) { - return true - } - - if (!arg) { - patchOverlayState({ modelPicker: true }) - - return true - } - - rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => { - if (stale() || !r) { - return - } - - if (!r.value) { - sys('error: invalid response: model switch') - - return - } - - sys(`model → ${r.value}`) - maybeWarn(r) - patchUiState(state => ({ - ...state, - info: state.info ? { ...state.info, model: r.value } : { model: r.value, skills: {}, tools: {} } - })) - }) - - return true - - case 'image': - rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => { - if (stale() || !r) { - return - } - - const meta = imageTokenMeta(r) - - sys(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) - r?.remainder && setInput(r.remainder) - }) - - return true - - case 'provider': - gw.request('slash.exec', { command: 'provider', session_id: sid }) - .then((r: any) => { - if (stale()) { - return - } - - page( - r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', - 'Provider' - ) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - - case 'skin': - if (arg) { - rpc('config.set', { key: 'skin', value: arg }).then((r: any) => { - if (stale() || !r?.value) { - return - } - - sys(`skin → ${r.value}`) - }) - } else { - rpc('config.get', { key: 'skin' }).then((r: any) => { - if (stale() || !r) { - return - } - - sys(`skin: ${r.value || 'default'}`) - }) - } - - return true - - case 'yolo': - rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => { - if (stale() || !r) { - return - } - - sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) - }) - - return true - - case 'reasoning': - if (!arg) { - rpc('config.get', { key: 'reasoning' }).then((r: any) => { - if (stale() || !r?.value) { - return - } - - sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) - }) - } else { - rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => { - if (stale() || !r?.value) { - return - } - - sys(`reasoning: ${r.value}`) - }) - } - - return true - - case 'verbose': - rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => { - if (stale() || !r?.value) { - return - } - - sys(`verbose: ${r.value}`) - }) - - return true - - case 'personality': - if (arg) { - rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { - if (stale() || !r) { - return - } - - r.history_reset && resetVisibleHistory(r.info ?? null) - sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`) - maybeWarn(r) - }) - - return true - } - - gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => { - if (stale()) { - return - } - - panel('Personality', [ - { - text: r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)' - } - ]) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - - case 'compress': - rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => { - if (stale() || !r) { - return - } - - Array.isArray(r.messages) && - setHistoryItems( - r.info ? [introMsg(r.info), ...toTranscriptMessages(r.messages)] : toTranscriptMessages(r.messages) - ) - r.info && patchUiState({ info: r.info }) - r.usage && patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } })) - - if ((r.removed ?? 0) <= 0) { - sys('nothing to compress') - - return - } - - sys(`compressed ${r.removed} messages${r.usage?.total ? ` · ${fmtK(r.usage.total)} tok` : ''}`) - }) - - return true - - case 'stop': - rpc('process.stop', {}).then((r: any) => { - if (stale() || !r) { - return - } - - sys(`killed ${r.killed ?? 0} registered process(es)`) - }) - - return true - - case 'branch': - case 'fork': { - const prevSid = sid - - rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { - if (stale() || !r?.session_id) { - return - } - - void closeSession(prevSid) - patchUiState({ sid: r.session_id }) - setSessionStartedAt(Date.now()) - setHistoryItems([]) - sys(`branched → ${r.title}`) - }) - - return true - } - - case 'reload-mcp': - - case 'reload_mcp': - rpc('reload.mcp', { session_id: sid }).then((r: any) => { - if (stale() || !r) { - return - } - - sys('MCP reloaded') - }) - - return true - - case 'title': - rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => { - if (stale() || !r) { - return - } - - sys(`title: ${r.title || '(none)'}`) - }) - - return true - - case 'usage': - rpc('session.usage', { session_id: sid }).then((r: any) => { - if (stale()) { - return - } - - if (r) { - patchUiState({ - usage: { input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 } - }) - } - - if (!r?.calls) { - sys('no API calls yet') - - return - } - - const f = (v: number) => (v ?? 0).toLocaleString() - - const cost = - r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null - - const rows: [string, string][] = [ - ['Model', r.model ?? ''], - ['Input tokens', f(r.input)], - ['Cache read tokens', f(r.cache_read)], - ['Cache write tokens', f(r.cache_write)], - ['Output tokens', f(r.output)], - ['Total tokens', f(r.total)], - ['API calls', f(r.calls)] - ] - - const sections: PanelSection[] = [{ rows }] - - cost && rows.push(['Cost', cost]) - r.context_max && - sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) - r.compressions && sections.push({ text: `Compressions: ${r.compressions}` }) - panel('Usage', sections) - }) - - return true - - case 'save': - rpc('session.save', { session_id: sid }).then((r: any) => { - if (stale() || !r?.file) { - return - } - - sys(`saved: ${r.file}`) - }) - - return true - - case 'history': - rpc('session.history', { session_id: sid }).then(r => { - if (stale() || typeof r?.count !== 'number') { - return - } - - if (!r.messages?.length) { - sys(`${r.count} messages`) - - return - } - - page( - r.messages - .map((msg, index) => - msg.role === 'tool' - ? `[Tool #${index + 1}] ${msg.name || 'tool'} ${msg.context || ''}`.trim() - : `[${msg.role === 'assistant' ? 'Hermes' : msg.role === 'user' ? 'You' : 'System'} #${index + 1}] ${msg.text || ''}`.trim() - ) - .join('\n\n'), - `History (${r.count})` - ) - }) - - return true - - case 'profile': - rpc('config.get', { key: 'profile' }).then((r: any) => { - if (stale() || !r) { - return - } - - const text = r.display || r.home || '(unknown profile)' - const lines = text.split('\n').filter(Boolean) - - lines.length <= 2 ? panel('Profile', [{ text }]) : page(text, 'Profile') - }) - - return true - - case 'voice': - rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => { - if (stale() || !r) { - return - } - - setVoiceEnabled(!!r?.enabled) - sys(`voice: ${r.enabled ? 'on' : 'off'}`) - }) - - return true - - case 'insights': - rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => { - if (stale() || !r) { - return - } - - panel('Insights', [ - { - rows: [ - ['Period', `${r.days} days`], - ['Sessions', `${r.sessions}`], - ['Messages', `${r.messages}`] - ] - } - ]) - }) - - return true - } - - return false - } -} - -interface SessionSlashCommand extends ParsedSlashCommand { - flight: number - sid: null | string -} diff --git a/ui-tui/src/app/slash/isStaleSlash.ts b/ui-tui/src/app/slash/isStaleSlash.ts deleted file mode 100644 index 0d8386fbd5..0000000000 --- a/ui-tui/src/app/slash/isStaleSlash.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { SlashHandlerContext } from '../interfaces.js' -import { getUiState } from '../uiStore.js' - -export function isStaleSlash( - ctx: Pick, - flight: number, - sid: null | string -): boolean { - return flight !== ctx.slashFlightRef.current || getUiState().sid !== sid -} diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts new file mode 100644 index 0000000000..3c7d1ee1d8 --- /dev/null +++ b/ui-tui/src/app/slash/registry.ts @@ -0,0 +1,18 @@ +import { coreCommands } from './commands/core.js' +import { opsCommands } from './commands/ops.js' +import { sessionCommands } from './commands/session.js' +import type { SlashCommand } from './types.js' + +export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands] + +const byName = new Map() + +for (const cmd of SLASH_COMMANDS) { + byName.set(cmd.name, cmd) + + for (const alias of cmd.aliases ?? []) { + byName.set(alias, cmd) + } +} + +export const findSlashCommand = (name: string): SlashCommand | undefined => byName.get(name.toLowerCase()) diff --git a/ui-tui/src/app/slash/shared.ts b/ui-tui/src/app/slash/shared.ts index e862045cf6..c6aba712b0 100644 --- a/ui-tui/src/app/slash/shared.ts +++ b/ui-tui/src/app/slash/shared.ts @@ -4,59 +4,35 @@ import type { SlashExecResponse } from '../../gatewayTypes.js' import { rpcErrorMessage } from '../../lib/rpc.js' import { getUiState } from '../uiStore.js' -export const parseSlashCommand = (cmd: string): ParsedSlashCommand => { - const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/) - - return { - arg: rest.join(' '), - cmd, - name: rawName.toLowerCase() - } -} - -export const createSlashShared = ({ gw, page, slashFlightRef, sys }: SlashSharedDeps): SlashShared => ({ - showSlashOutput: ({ command, flight, sid, title }) => { - gw.request('slash.exec', { command, session_id: sid }) - .then(r => { - if (flight !== slashFlightRef.current || getUiState().sid !== sid) { - return - } - - const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)' - - const lines = text.split('\n').filter(Boolean) - - if (lines.length > 2 || text.length > 180) { - page(text, title) - } else { - sys(text) - } - }) - .catch((e: unknown) => { - if (flight !== slashFlightRef.current || getUiState().sid !== sid) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - } -}) - -export interface ParsedSlashCommand { - arg: string - cmd: string - name: string -} - export interface SlashShared { showSlashOutput: (opts: { command: string; flight: number; sid: null | string; title: string }) => void } interface SlashSharedDeps { - gw: { - request: (method: string, params?: Record) => Promise - } + gw: { request: (method: string, params?: Record) => Promise } page: (text: string, title?: string) => void slashFlightRef: MutableRefObject sys: (text: string) => void } + +export const createSlashShared = ({ gw, page, slashFlightRef, sys }: SlashSharedDeps): SlashShared => ({ + showSlashOutput: ({ command, flight, sid, title }) => { + const stale = () => flight !== slashFlightRef.current || getUiState().sid !== sid + + gw.request('slash.exec', { command, session_id: sid }) + .then(r => { + if (stale()) { + return + } + + const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)' + + text.split('\n').filter(Boolean).length > 2 || text.length > 180 ? page(text, title) : sys(text) + }) + .catch((e: unknown) => { + if (!stale()) { + sys(`error: ${rpcErrorMessage(e)}`) + } + }) + } +}) diff --git a/ui-tui/src/app/slash/types.ts b/ui-tui/src/app/slash/types.ts new file mode 100644 index 0000000000..4fa6e0b595 --- /dev/null +++ b/ui-tui/src/app/slash/types.ts @@ -0,0 +1,24 @@ +import type { MutableRefObject } from 'react' + +import type { SlashHandlerContext, UiState } from '../interfaces.js' + +import type { SlashShared } from './shared.js' + +export interface SlashRunCtx extends SlashHandlerContext { + flight: number + guarded: (fn: (r: T) => void) => (r: null | T) => void + guardedErr: (e: unknown) => void + shared: SlashShared + sid: null | string + slashFlightRef: MutableRefObject + stale: () => boolean + ui: UiState +} + +export interface SlashCommand { + aliases?: string[] + help?: string + name: string + run: (arg: string, ctx: SlashRunCtx, cmd: string) => void + usage?: string +} diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts new file mode 100644 index 0000000000..df2814277f --- /dev/null +++ b/ui-tui/src/app/turnController.ts @@ -0,0 +1,353 @@ +import { REASONING_PULSE_MS, STREAM_BATCH_MS } from '../config/timing.js' +import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js' +import { + buildToolTrailLine, + estimateTokensRough, + isToolTrailResultLine, + isTransientTrailLine, + sameToolTrailGroup, + toolTrailLabel +} from '../lib/text.js' +import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js' + +import { resetOverlayState } from './overlayStore.js' +import { patchTurnState, resetTurnState } from './turnStore.js' +import { patchUiState } from './uiStore.js' + +const INTERRUPT_COOLDOWN_MS = 1500 +const ACTIVITY_LIMIT = 8 +const TRAIL_LIMIT = 8 + +export interface InterruptDeps { + appendMessage: (msg: Msg) => void + gw: { request: (method: string, params?: Record) => Promise } + sid: string + sys: (text: string) => void +} + +type Timer = null | ReturnType + +const clear = (t: Timer): null => { + if (t) { + clearTimeout(t) + } + + return null +} + +class TurnController { + bufRef = '' + interrupted = false + lastStatusNote = '' + persistedToolLabels = new Set() + protocolWarned = false + reasoningText = '' + statusTimer: Timer = null + toolTokenAcc = 0 + turnTools: string[] = [] + + private activeTools: ActiveTool[] = [] + private activityId = 0 + private reasoningStreamingTimer: Timer = null + private reasoningTimer: Timer = null + private streamTimer: Timer = null + private toolProgressTimer: Timer = null + + clearReasoning() { + this.reasoningTimer = clear(this.reasoningTimer) + this.reasoningText = '' + this.toolTokenAcc = 0 + patchTurnState({ reasoning: '', reasoningTokens: 0, toolTokens: 0 }) + } + + clearStatusTimer() { + this.statusTimer = clear(this.statusTimer) + } + + endReasoningPhase() { + this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) + patchTurnState({ reasoningActive: false, reasoningStreaming: false }) + } + + idle() { + this.endReasoningPhase() + this.activeTools = [] + this.streamTimer = clear(this.streamTimer) + this.bufRef = '' + + patchTurnState({ streaming: '', subagents: [], tools: [], turnTrail: [] }) + patchUiState({ busy: false }) + resetOverlayState() + } + + interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) { + this.interrupted = true + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + + const partial = this.bufRef.trimStart() + + partial ? appendMessage({ role: 'assistant', text: `${partial}\n\n*[interrupted]*` }) : sys('interrupted') + + this.idle() + this.clearReasoning() + this.turnTools = [] + patchTurnState({ activity: [] }) + patchUiState({ status: 'interrupted' }) + this.clearStatusTimer() + + this.statusTimer = setTimeout(() => { + this.statusTimer = null + patchUiState({ status: 'ready' }) + }, INTERRUPT_COOLDOWN_MS) + } + + pruneTransient() { + this.turnTools = this.turnTools.filter(line => !isTransientTrailLine(line)) + patchTurnState(state => { + const next = state.turnTrail.filter(line => !isTransientTrailLine(line)) + + return next.length === state.turnTrail.length ? state : { ...state, turnTrail: next } + }) + } + + pulseReasoningStreaming() { + this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) + patchTurnState({ reasoningActive: true, reasoningStreaming: true }) + + this.reasoningStreamingTimer = setTimeout(() => { + this.reasoningStreamingTimer = null + patchTurnState({ reasoningStreaming: false }) + }, REASONING_PULSE_MS) + } + + pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) { + patchTurnState(state => { + const base = replaceLabel + ? state.activity.filter(item => !sameToolTrailGroup(replaceLabel, item.text)) + : state.activity + + const tail = base.at(-1) + + if (tail?.text === text && tail.tone === tone) { + return state + } + + return { ...state, activity: [...base, { id: ++this.activityId, text, tone }].slice(-ACTIVITY_LIMIT) } + }) + } + + pushTrail(line: string) { + patchTurnState(state => { + if (state.turnTrail.at(-1) === line) { + return state + } + + const next = [...state.turnTrail.filter(item => !isTransientTrailLine(item)), line].slice(-TRAIL_LIMIT) + + this.turnTools = next + + return { ...state, turnTrail: next } + }) + } + + recordError() { + this.idle() + this.clearReasoning() + this.clearStatusTimer() + this.turnTools = [] + this.persistedToolLabels.clear() + } + + 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 savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 + const savedToolTokens = this.toolTokenAcc + const persisted = [...this.persistedToolLabels] + + const savedTools = this.turnTools.filter( + line => isToolTrailResultLine(line) && !persisted.some(label => sameToolTrailGroup(label, line)) + ) + + const wasInterrupted = this.interrupted + + this.idle() + this.clearReasoning() + this.turnTools = [] + this.persistedToolLabels.clear() + this.bufRef = '' + patchTurnState({ activity: [] }) + + return { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted } + } + + recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) { + this.pruneTransient() + this.endReasoningPhase() + + if (!text || this.interrupted) { + return + } + + this.bufRef = rendered ?? this.bufRef + text + this.scheduleStreaming() + } + + recordReasoningAvailable(text: string) { + const incoming = text.trim() + + if (!incoming || this.reasoningText.trim()) { + return + } + + this.reasoningText = incoming + this.scheduleReasoning() + this.pulseReasoningStreaming() + } + + recordReasoningDelta(text: string) { + this.reasoningText += text + this.scheduleReasoning() + this.pulseReasoningStreaming() + } + + recordToolComplete(toolId: string, fallbackName?: string, error?: string, summary?: string) { + const done = this.activeTools.find(tool => tool.id === toolId) + const name = done?.name ?? fallbackName ?? 'tool' + const label = toolTrailLabel(name) + const line = buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '') + + this.activeTools = this.activeTools.filter(tool => tool.id !== toolId) + + const next = [...this.turnTools.filter(item => !sameToolTrailGroup(label, item)), line] + + if (!this.activeTools.length) { + next.push('analyzing tool output…') + } + + this.turnTools = next.slice(-TRAIL_LIMIT) + patchTurnState({ tools: this.activeTools, turnTrail: this.turnTools }) + } + + recordToolProgress(toolName: string, preview: string) { + const index = this.activeTools.findIndex(tool => tool.name === toolName) + + if (index < 0) { + return + } + + this.activeTools = this.activeTools.map((tool, i) => (i === index ? { ...tool, context: preview } : tool)) + + if (this.toolProgressTimer) { + return + } + + this.toolProgressTimer = setTimeout(() => { + this.toolProgressTimer = null + patchTurnState({ tools: [...this.activeTools] }) + }, STREAM_BATCH_MS) + } + + recordToolStart(toolId: string, name: string, context: string) { + this.pruneTransient() + this.endReasoningPhase() + + const sample = `${name} ${context}`.trim() + + this.toolTokenAcc += sample ? estimateTokensRough(sample) : 0 + this.activeTools = [...this.activeTools, { context, id: toolId, name, startedAt: Date.now() }] + + patchTurnState({ toolTokens: this.toolTokenAcc, tools: this.activeTools }) + } + + reset() { + this.clearReasoning() + this.clearStatusTimer() + this.idle() + this.bufRef = '' + this.interrupted = false + this.lastStatusNote = '' + this.protocolWarned = false + this.turnTools = [] + this.toolTokenAcc = 0 + this.persistedToolLabels.clear() + patchTurnState({ activity: [] }) + } + + fullReset() { + this.reset() + resetTurnState() + } + + scheduleReasoning() { + if (this.reasoningTimer) { + return + } + + this.reasoningTimer = setTimeout(() => { + this.reasoningTimer = null + patchTurnState({ + reasoning: this.reasoningText, + reasoningTokens: estimateTokensRough(this.reasoningText) + }) + }, STREAM_BATCH_MS) + } + + scheduleStreaming() { + if (this.streamTimer) { + return + } + + this.streamTimer = setTimeout(() => { + this.streamTimer = null + patchTurnState({ streaming: this.bufRef.trimStart() }) + }, STREAM_BATCH_MS) + } + + startMessage() { + this.endReasoningPhase() + this.clearReasoning() + this.activeTools = [] + this.turnTools = [] + this.toolTokenAcc = 0 + this.persistedToolLabels.clear() + patchUiState({ busy: true }) + patchTurnState({ activity: [], subagents: [], toolTokens: 0, tools: [], turnTrail: [] }) + } + + upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial) { + const id = `sa:${p.task_index}:${p.goal || 'subagent'}` + + patchTurnState(state => { + const existing = state.subagents.find(item => item.id === id) + + const base: SubagentProgress = existing ?? { + goal: p.goal, + id, + index: p.task_index, + notes: [], + status: 'running', + taskCount: p.task_count ?? 1, + thinking: [], + tools: [] + } + + const next: SubagentProgress = { + ...base, + goal: p.goal || base.goal, + taskCount: p.task_count ?? base.taskCount, + ...patch(base) + } + + const subagents = existing + ? state.subagents.map(item => (item.id === id ? next : item)) + : [...state.subagents, next].sort((a, b) => a.index - b.index) + + return { ...state, subagents } + }) + } +} + +export const turnController = new TurnController() + +export type { TurnController } diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts new file mode 100644 index 0000000000..f4166ea8d1 --- /dev/null +++ b/ui-tui/src/app/turnStore.ts @@ -0,0 +1,47 @@ +import { atom } from 'nanostores' + +import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' + +export interface TurnState { + activity: ActivityItem[] + reasoning: string + reasoningActive: boolean + reasoningStreaming: boolean + reasoningTokens: number + streaming: string + subagents: SubagentProgress[] + toolTokens: number + tools: ActiveTool[] + turnTrail: string[] +} + +function buildTurnState(): TurnState { + return { + activity: [], + reasoning: '', + reasoningActive: false, + reasoningStreaming: false, + reasoningTokens: 0, + streaming: '', + subagents: [], + toolTokens: 0, + tools: [], + turnTrail: [] + } +} + +export const $turnState = atom(buildTurnState()) + +export const getTurnState = () => $turnState.get() + +export const patchTurnState = (next: Partial | ((state: TurnState) => TurnState)) => { + if (typeof next === 'function') { + $turnState.set(next($turnState.get())) + + return + } + + $turnState.set({ ...$turnState.get(), ...next }) +} + +export const resetTurnState = () => $turnState.set(buildTurnState()) diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 501db36c92..868f2ba5e5 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -1,6 +1,6 @@ import { atom } from 'nanostores' -import { ZERO } from '../constants.js' +import { ZERO } from '../domain/usage.js' import { DEFAULT_THEME } from '../theme.js' import type { UiState } from './interfaces.js' diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 467b01614e..a4ccb1f016 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -7,12 +7,12 @@ import { useStore } from '@nanostores/react' import { useCallback, useMemo, useState } from 'react' import type { PasteEvent } from '../components/textInput.js' +import { LARGE_PASTE } from '../config/limits.js' import { useCompletion } from '../hooks/useCompletion.js' import { useInputHistory } from '../hooks/useInputHistory.js' import { useQueue } from '../hooks/useQueue.js' import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' -import { LARGE_PASTE } from './constants.js' import type { PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js' import { $isBlocked } from './overlayStore.js' diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts new file mode 100644 index 0000000000..6a6edb4df2 --- /dev/null +++ b/ui-tui/src/app/useConfigSync.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef } from 'react' + +import { resolveDetailsMode } from '../domain/details.js' +import type { + ConfigFullResponse, + ConfigMtimeResponse, + ReloadMcpResponse, + VoiceToggleResponse +} from '../gatewayTypes.js' + +import type { GatewayRpc } from './interfaces.js' +import { turnController } from './turnController.js' +import { patchUiState } from './uiStore.js' + +const MTIME_POLL_MS = 5000 + +const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => { + const display = cfg?.config?.display ?? {} + + setBell(!!display.bell_on_complete) + patchUiState({ + compact: !!display.tui_compact, + detailsMode: resolveDetailsMode(display), + statusBar: display.tui_statusbar !== false + }) +} + +export interface UseConfigSyncOptions { + rpc: GatewayRpc + setBellOnComplete: (v: boolean) => void + setVoiceEnabled: (v: boolean) => void + sid: null | string +} + +export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) { + const mtimeRef = useRef(0) + + useEffect(() => { + if (!sid) { + return + } + + rpc('voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled)) + rpc('config.get', { key: 'mtime' }).then(r => { + mtimeRef.current = Number(r?.mtime ?? 0) + }) + rpc('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rpc, sid]) + + useEffect(() => { + if (!sid) { + return + } + + const id = setInterval(() => { + rpc('config.get', { key: 'mtime' }).then(r => { + const next = Number(r?.mtime ?? 0) + + if (!mtimeRef.current) { + if (next) { + mtimeRef.current = next + } + + return + } + + if (!next || next === mtimeRef.current) { + return + } + + mtimeRef.current = next + + rpc('reload.mcp', { session_id: sid }).then( + r => r && turnController.pushActivity('MCP reloaded after config change') + ) + rpc('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) + }) + }, MTIME_POLL_MS) + + return () => clearInterval(id) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rpc, sid]) +} diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 3f23d3e6c1..5359341504 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -1,77 +1,178 @@ import { useInput } from '@hermes/ink' import { useStore } from '@nanostores/react' +import type { + ApprovalRespondResponse, + SecretRespondResponse, + SudoRespondResponse, + VoiceRecordResponse +} from '../gatewayTypes.js' + import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' import { getUiState, patchUiState } from './uiStore.js' +const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target + export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { - const { actions, composer, gateway, terminal, turn, voice, wheelStep } = ctx + const { actions, composer, gateway, terminal, voice, wheelStep } = ctx + const { actions: cActions, refs: cRefs, state: cState } = composer + const overlay = useStore($overlayState) const isBlocked = useStore($isBlocked) const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6) - const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target - const copySelection = () => { if (terminal.selection.copySelection()) { actions.sys('copied selection') } } + const cancelOverlayFromCtrlC = (live: ReturnType) => { + if (overlay.clarify) { + return actions.answerClarify('') + } + + if (overlay.approval) { + return gateway + .rpc('approval.respond', { choice: 'deny', session_id: live.sid }) + .then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied'))) + } + + if (overlay.sudo) { + return gateway + .rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }) + .then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled'))) + } + + if (overlay.secret) { + return gateway + .rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }) + .then(r => r && (patchOverlayState({ secret: null }), actions.sys('secret entry cancelled'))) + } + + if (overlay.modelPicker) { + return patchOverlayState({ modelPicker: false }) + } + + if (overlay.picker) { + return patchOverlayState({ picker: false }) + } + } + + const cycleQueue = (dir: 1 | -1) => { + const len = cRefs.queueRef.current.length + + if (!len) { + return false + } + + const index = cState.queueEditIdx === null ? (dir > 0 ? 0 : len - 1) : (cState.queueEditIdx + dir + len) % len + + cActions.setQueueEdit(index) + cActions.setHistoryIdx(null) + cActions.setInput(cRefs.queueRef.current[index] ?? '') + + return true + } + + const cycleHistory = (dir: 1 | -1) => { + const h = cRefs.historyRef.current + const cur = cState.historyIdx + + if (dir < 0) { + if (!h.length) { + return + } + + if (cur === null) { + cRefs.historyDraftRef.current = cState.input + } + + const index = cur === null ? h.length - 1 : Math.max(0, cur - 1) + + cActions.setHistoryIdx(index) + cActions.setQueueEdit(null) + cActions.setInput(h[index] ?? '') + + return + } + + if (cur === null) { + return + } + + const next = cur + 1 + + if (next >= h.length) { + cActions.setHistoryIdx(null) + cActions.setInput(cRefs.historyDraftRef.current) + } else { + cActions.setHistoryIdx(next) + cActions.setInput(h[next] ?? '') + } + } + + const voiceStop = () => { + voice.setRecording(false) + voice.setProcessing(true) + + gateway + .rpc('voice.record', { action: 'stop' }) + .then(r => { + if (!r) { + return + } + + const transcript = String(r.text || '').trim() + + if (!transcript) { + return actions.sys('voice: no speech detected') + } + + cActions.setInput(prev => (prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript)) + }) + .catch((e: Error) => actions.sys(`voice error: ${e.message}`)) + .finally(() => { + voice.setProcessing(false) + patchUiState({ status: 'ready' }) + }) + } + + const voiceStart = () => + gateway + .rpc('voice.record', { action: 'start' }) + .then(r => { + if (!r) { + return + } + + voice.setRecording(true) + patchUiState({ status: 'recording…' }) + }) + .catch((e: Error) => actions.sys(`voice error: ${e.message}`)) + useInput((ch, key) => { const live = getUiState() if (isBlocked) { if (overlay.pager) { if (key.return || ch === ' ') { - const next = overlay.pager.offset + pagerPageSize + const nextOffset = overlay.pager.offset + pagerPageSize patchOverlayState({ - pager: next >= overlay.pager.lines.length ? null : { ...overlay.pager, offset: next } + pager: nextOffset >= overlay.pager.lines.length ? null : { ...overlay.pager, offset: nextOffset } }) - } else if (key.escape || ctrl(key, ch, 'c') || ch === 'q') { + } else if (key.escape || isCtrl(key, ch, 'c') || ch === 'q') { patchOverlayState({ pager: null }) } return } - if (ctrl(key, ch, 'c')) { - if (overlay.clarify) { - actions.answerClarify('') - } else if (overlay.approval) { - gateway.rpc('approval.respond', { choice: 'deny', session_id: live.sid }).then(r => { - if (!r) { - return - } - - patchOverlayState({ approval: null }) - actions.sys('denied') - }) - } else if (overlay.sudo) { - gateway.rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }).then(r => { - if (!r) { - return - } - - patchOverlayState({ sudo: null }) - actions.sys('sudo cancelled') - }) - } else if (overlay.secret) { - gateway.rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }).then(r => { - if (!r) { - return - } - - patchOverlayState({ secret: null }) - actions.sys('secret entry cancelled') - }) - } else if (overlay.modelPicker) { - patchOverlayState({ modelPicker: false }) - } else if (overlay.picker) { - patchOverlayState({ picker: false }) - } + if (isCtrl(key, ch, 'c')) { + cancelOverlayFromCtrlC(live) } else if (key.escape && overlay.picker) { patchOverlayState({ picker: false }) } @@ -79,215 +180,116 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - if ( - composer.state.completions.length && - composer.state.input && - composer.state.historyIdx === null && - (key.upArrow || key.downArrow) - ) { - composer.actions.setCompIdx(index => - key.upArrow - ? (index - 1 + composer.state.completions.length) % composer.state.completions.length - : (index + 1) % composer.state.completions.length - ) + if (cState.completions.length && cState.input && cState.historyIdx === null && (key.upArrow || key.downArrow)) { + const len = cState.completions.length + + cActions.setCompIdx(i => (key.upArrow ? (i - 1 + len) % len : (i + 1) % len)) return } if (key.wheelUp) { - terminal.scrollWithSelection(-wheelStep) - - return + return terminal.scrollWithSelection(-wheelStep) } if (key.wheelDown) { - terminal.scrollWithSelection(wheelStep) - - return + return terminal.scrollWithSelection(wheelStep) } if (key.shift && key.upArrow) { - terminal.scrollWithSelection(-1) - - return + return terminal.scrollWithSelection(-1) } if (key.shift && key.downArrow) { - terminal.scrollWithSelection(1) - - return + return terminal.scrollWithSelection(1) } if (key.pageUp || key.pageDown) { const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8) const step = Math.max(4, viewport - 2) - terminal.scrollWithSelection(key.pageUp ? -step : step) - - return + return terminal.scrollWithSelection(key.pageUp ? -step : step) } if (key.ctrl && key.shift && ch.toLowerCase() === 'c') { - copySelection() + return copySelection() + } + + if (key.upArrow && !cState.inputBuf.length) { + cycleQueue(1) || cycleHistory(-1) return } - if (key.upArrow && !composer.state.inputBuf.length) { - if (composer.refs.queueRef.current.length) { - const index = - composer.state.queueEditIdx === null - ? 0 - : (composer.state.queueEditIdx + 1) % composer.refs.queueRef.current.length - - composer.actions.setQueueEdit(index) - composer.actions.setHistoryIdx(null) - composer.actions.setInput(composer.refs.queueRef.current[index] ?? '') - } else if (composer.refs.historyRef.current.length) { - const index = - composer.state.historyIdx === null - ? composer.refs.historyRef.current.length - 1 - : Math.max(0, composer.state.historyIdx - 1) - - if (composer.state.historyIdx === null) { - composer.refs.historyDraftRef.current = composer.state.input - } - - composer.actions.setHistoryIdx(index) - composer.actions.setQueueEdit(null) - composer.actions.setInput(composer.refs.historyRef.current[index] ?? '') - } + if (key.downArrow && !cState.inputBuf.length) { + cycleQueue(-1) || cycleHistory(1) return } - if (key.downArrow && !composer.state.inputBuf.length) { - if (composer.refs.queueRef.current.length) { - const index = - composer.state.queueEditIdx === null - ? composer.refs.queueRef.current.length - 1 - : (composer.state.queueEditIdx - 1 + composer.refs.queueRef.current.length) % - composer.refs.queueRef.current.length - - composer.actions.setQueueEdit(index) - composer.actions.setHistoryIdx(null) - composer.actions.setInput(composer.refs.queueRef.current[index] ?? '') - } else if (composer.state.historyIdx !== null) { - const next = composer.state.historyIdx + 1 - - if (next >= composer.refs.historyRef.current.length) { - composer.actions.setHistoryIdx(null) - composer.actions.setInput(composer.refs.historyDraftRef.current) - } else { - composer.actions.setHistoryIdx(next) - composer.actions.setInput(composer.refs.historyRef.current[next] ?? '') - } - } - - return - } - - if (ctrl(key, ch, 'c')) { + if (isCtrl(key, ch, 'c')) { if (terminal.hasSelection) { - copySelection() - } else if (live.busy && live.sid) { - turn.actions.interruptTurn({ + return copySelection() + } + + if (live.busy && live.sid) { + return turnController.interruptTurn({ appendMessage: actions.appendMessage, gw: gateway.gw, sid: live.sid, sys: actions.sys }) - } else if (composer.state.input || composer.state.inputBuf.length) { - composer.actions.clearIn() - } else { - return actions.die() } - return - } + if (cState.input || cState.inputBuf.length) { + return cActions.clearIn() + } - if (ctrl(key, ch, 'd')) { return actions.die() } - if (ctrl(key, ch, 'l')) { + if (isCtrl(key, ch, 'd')) { + return actions.die() + } + + if (isCtrl(key, ch, 'l')) { if (actions.guardBusySessionSwitch()) { return } patchUiState({ status: 'forging session…' }) - actions.newSession() - return + return actions.newSession() } - if (ctrl(key, ch, 'b')) { - if (voice.recording) { - voice.setRecording(false) - voice.setProcessing(true) - gateway - .rpc('voice.record', { action: 'stop' }) - .then((r: any) => { - if (!r) { - return - } - - const transcript = String(r?.text || '').trim() - - if (transcript) { - composer.actions.setInput(prev => - prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript - ) - } else { - actions.sys('voice: no speech detected') - } - }) - .catch((e: Error) => actions.sys(`voice error: ${e.message}`)) - .finally(() => { - voice.setProcessing(false) - patchUiState({ status: 'ready' }) - }) - } else { - gateway - .rpc('voice.record', { action: 'start' }) - .then((r: any) => { - if (!r) { - return - } - - voice.setRecording(true) - patchUiState({ status: 'recording…' }) - }) - .catch((e: Error) => actions.sys(`voice error: ${e.message}`)) - } - - return + if (isCtrl(key, ch, 'b')) { + return voice.recording ? voiceStop() : voiceStart() } - if (ctrl(key, ch, 'g')) { - return composer.actions.openEditor() + if (isCtrl(key, ch, 'g')) { + return cActions.openEditor() } - if (key.tab && composer.state.completions.length) { - const row = composer.state.completions[composer.state.compIdx] + if (key.tab && cState.completions.length) { + const row = cState.completions[cState.compIdx] if (row?.text) { const text = - composer.state.input.startsWith('/') && row.text.startsWith('/') && composer.state.compReplace > 0 + cState.input.startsWith('/') && row.text.startsWith('/') && cState.compReplace > 0 ? row.text.slice(1) : row.text - composer.actions.setInput(composer.state.input.slice(0, composer.state.compReplace) + text) + cActions.setInput(cState.input.slice(0, cState.compReplace) + text) } return } - if (ctrl(key, ch, 'k') && composer.refs.queueRef.current.length && live.sid) { - const next = composer.actions.dequeue() + if (isCtrl(key, ch, 'k') && cRefs.queueRef.current.length && live.sid) { + const next = cActions.dequeue() if (next) { - composer.actions.setQueueEdit(null) + cActions.setQueueEdit(null) actions.dispatchSubmission(next) } } diff --git a/ui-tui/src/app/useLongRunToolCharms.ts b/ui-tui/src/app/useLongRunToolCharms.ts index 63583fb70d..60a871c193 100644 --- a/ui-tui/src/app/useLongRunToolCharms.ts +++ b/ui-tui/src/app/useLongRunToolCharms.ts @@ -1,23 +1,26 @@ import { useEffect, useRef } from 'react' -import { toolTrailLabel } from '../lib/text.js' -import type { ActiveTool, ActivityItem } from '../types.js' +import { LONG_RUN_CHARMS } from '../content/charms.js' +import { pick, toolTrailLabel } from '../lib/text.js' +import type { ActiveTool } from '../types.js' + +import { turnController } from './turnController.js' const DELAY_MS = 8_000 const INTERVAL_MS = 10_000 -const MAX = 2 -const CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…'] +const MAX_CHARMS_PER_TOOL = 2 -export function useLongRunToolCharms( - busy: boolean, - tools: ActiveTool[], - pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void -) { - const slotRef = useRef(new Map()) +interface Slot { + count: number + lastAt: number +} + +export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) { + const slots = useRef(new Map()) useEffect(() => { if (!busy || !tools.length) { - slotRef.current.clear() + slots.current.clear() return } @@ -26,9 +29,9 @@ export function useLongRunToolCharms( const now = Date.now() const liveIds = new Set(tools.map(t => t.id)) - for (const key of [...slotRef.current.keys()]) { + for (const key of [...slots.current.keys()]) { if (!liveIds.has(key)) { - slotRef.current.delete(key) + slots.current.delete(key) } } @@ -37,20 +40,17 @@ export function useLongRunToolCharms( continue } - const slot = slotRef.current.get(tool.id) ?? { count: 0, lastAt: 0 } + const slot = slots.current.get(tool.id) ?? { count: 0, lastAt: 0 } - if (slot.count >= MAX || now - slot.lastAt < INTERVAL_MS) { + if (slot.count >= MAX_CHARMS_PER_TOOL || now - slot.lastAt < INTERVAL_MS) { continue } - slot.count += 1 - slot.lastAt = now - slotRef.current.set(tool.id, slot) + slots.current.set(tool.id, { count: slot.count + 1, lastAt: now }) - const charm = CHARMS[Math.floor(Math.random() * CHARMS.length)]! const sec = Math.round((now - tool.startedAt) / 1000) - pushActivity(`${charm} (${toolTrailLabel(tool.name)} · ${sec}s)`) + turnController.pushActivity(`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${sec}s)`) } } @@ -58,5 +58,5 @@ export function useLongRunToolCharms( const id = setInterval(tick, 1000) return () => clearInterval(id) - }, [busy, pushActivity, tools]) + }, [busy, tools]) } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 1abc4bdde1..f2827dfdfa 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -2,34 +2,69 @@ import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { INTERPOLATION_RE, ZERO } from '../constants.js' +import { STARTUP_RESUME_ID } from '../config/env.js' +import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' +import { imageTokenMeta } from '../domain/messages.js' +import { shortCwd } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' -import type { ConfigFullResponse, ConfigMtimeResponse, GatewayEvent, SessionCreateResponse } from '../gatewayTypes.js' +import type { + ClarifyRespondResponse, + ClipboardPasteResponse, + GatewayEvent, + TerminalResizeResponse +} from '../gatewayTypes.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' -import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' -import type { Msg, PanelSection, SessionInfo, SlashCatalog } from '../types.js' +import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' +import type { Msg, PanelSection, SlashCatalog } from '../types.js' -import { MAX_HISTORY, PASTE_SNIPPET_RE, STARTUP_RESUME_ID, WHEEL_SCROLL_STEP } from './constants.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' import { createSlashHandler } from './createSlashHandler.js' -import { - imageTokenMeta, - introMsg, - looksLikeSlashCommand, - resolveDetailsMode, - shortCwd, - toTranscriptMessages -} from './helpers.js' import { type GatewayRpc, type TranscriptRow } from './interfaces.js' -import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' +import { $overlayState, patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' +import { $turnState, patchTurnState } from './turnStore.js' import { $uiState, getUiState, patchUiState } from './uiStore.js' import { useComposerState } from './useComposerState.js' +import { useConfigSync } from './useConfigSync.js' import { useInputHandlers } from './useInputHandlers.js' import { useLongRunToolCharms } from './useLongRunToolCharms.js' -import { useTurnState } from './useTurnState.js' +import { useSessionLifecycle } from './useSessionLifecycle.js' +import { useSubmission } from './useSubmission.js' const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i +const BRACKET_PASTE_ON = '\x1b[?2004h' +const BRACKET_PASTE_OFF = '\x1b[?2004l' + +const capHistory = (items: Msg[]): Msg[] => { + if (items.length <= MAX_HISTORY) { + return items + } + + return items[0]?.kind === 'intro' ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY) +} + +const statusColorOf = (status: string, t: { dim: string; error: string; ok: string; warn: string }) => { + if (status === 'ready') { + return t.ok + } + + if (status.startsWith('error')) { + return t.error + } + + if (status === 'interrupted') { + return t.warn + } + + return t.dim +} + +interface SelectionSnap { + anchor?: { row: number } + focus?: { row: number } + isDragging?: boolean +} export function useMainApp(gw: GatewayClient) { const { exit } = useApp() @@ -42,17 +77,18 @@ export function useMainApp(gw: GatewayClient) { } const sync = () => setCols(stdout.columns ?? 80) + stdout.on('resize', sync) if (stdout.isTTY) { - stdout.write('\x1b[?2004h') + stdout.write(BRACKET_PASTE_ON) } return () => { stdout.off('resize', sync) if (stdout.isTTY) { - stdout.write('\x1b[?2004l') + stdout.write(BRACKET_PASTE_OFF) } } }, [stdout]) @@ -60,40 +96,36 @@ export function useMainApp(gw: GatewayClient) { const [historyItems, setHistoryItems] = useState([]) const [lastUserMsg, setLastUserMsg] = useState('') const [stickyPrompt, setStickyPrompt] = useState('') - const [catalog, setCatalog] = useState(null) + const [catalog, setCatalog] = useState(null) const [voiceEnabled, setVoiceEnabled] = useState(false) const [voiceRecording, setVoiceRecording] = useState(false) const [voiceProcessing, setVoiceProcessing] = useState(false) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [goodVibesTick, setGoodVibesTick] = useState(0) const [bellOnComplete, setBellOnComplete] = useState(false) + const ui = useStore($uiState) const overlay = useStore($overlayState) - const isBlocked = useStore($isBlocked) + const turn = useStore($turnState) const slashFlightRef = useRef(0) const slashRef = useRef<(cmd: string) => boolean>(() => false) - const lastEmptyAt = useRef(0) const colsRef = useRef(cols) - const scrollRef = useRef(null) + const scrollRef = useRef(null) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) const clipboardPasteRef = useRef<(quiet?: boolean) => Promise | void>(() => {}) const submitRef = useRef<(value: string) => void>(() => {}) - const configMtimeRef = useRef(0) const historyItemsRef = useRef(historyItems) const lastUserMsgRef = useRef(lastUserMsg) const msgIdsRef = useRef(new WeakMap()) const nextMsgIdRef = useRef(0) + colsRef.current = cols historyItemsRef.current = historyItems lastUserMsgRef.current = lastUserMsg const hasSelection = useHasSelection() const selection = useSelection() - const turn = useTurnState() - const turnActions = turn.actions - const turnRefs = turn.refs - const turnState = turn.state const composer = useComposerState({ gw, @@ -101,10 +133,7 @@ export function useMainApp(gw: GatewayClient) { submitRef }) - const composerActions = composer.actions - const composerRefs = composer.refs - const composerState = composer.state - + const { actions: composerActions, refs: composerRefs, state: composerState } = composer const empty = !historyItems.some(msg => msg.kind !== 'intro') const messageId = useCallback((msg: Msg) => { @@ -115,18 +144,14 @@ export function useMainApp(gw: GatewayClient) { } const next = `m${++nextMsgIdRef.current}` + msgIdsRef.current.set(msg, next) return next }, []) const virtualRows = useMemo( - () => - historyItems.map((msg, index) => ({ - index, - key: messageId(msg), - msg - })), + () => historyItems.map((msg, index) => ({ index, key: messageId(msg), msg })), [historyItems, messageId] ) @@ -136,31 +161,24 @@ export function useMainApp(gw: GatewayClient) { (delta: number) => { const s = scrollRef.current - const sel = selection.getState() as { - anchor?: { row: number } - focus?: { row: number } - isDragging?: boolean - } | null - - if (!s || !sel?.anchor || !sel.focus) { - s?.scrollBy(delta) - + if (!s) { return } + const sel = selection.getState() as null | SelectionSnap + + const focusOutside = (top: number, bottom: number) => + !sel?.anchor || + !sel.focus || + sel.anchor.row < top || + sel.anchor.row > bottom || + (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) + const top = s.getViewportTop() const bottom = top + s.getViewportHeight() - 1 - if (sel.anchor.row < top || sel.anchor.row > bottom) { - s.scrollBy(delta) - - return - } - - if (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) { - s.scrollBy(delta) - - return + if (focusOutside(top, bottom)) { + return s.scrollBy(delta) } const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) @@ -171,13 +189,14 @@ export function useMainApp(gw: GatewayClient) { return } + const shift = sel!.isDragging ? selection.shiftAnchor : selection.shiftSelection + if (actual > 0) { selection.captureScrolledRows(top, top + actual - 1, 'above') - sel.isDragging ? selection.shiftAnchor(-actual, top, bottom) : selection.shiftSelection(-actual, top, bottom) + shift(-actual, top, bottom) } else { - const amount = -actual - selection.captureScrolledRows(bottom - amount + 1, bottom, 'below') - sel.isDragging ? selection.shiftAnchor(amount, top, bottom) : selection.shiftSelection(amount, top, bottom) + selection.captureScrolledRows(bottom + actual + 1, bottom, 'below') + shift(-actual, top, bottom) } s.scrollBy(delta) @@ -185,57 +204,36 @@ export function useMainApp(gw: GatewayClient) { [selection] ) - const appendMessage = useCallback((msg: Msg) => { - const cap = (items: Msg[]) => - items.length <= MAX_HISTORY - ? items - : items[0]?.kind === 'intro' - ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] - : items.slice(-MAX_HISTORY) + const appendMessage = useCallback((msg: Msg) => setHistoryItems(prev => capHistory([...prev, msg])), []) - setHistoryItems(prev => cap([...prev, msg])) - }, []) + const sys = useCallback((text: string) => appendMessage({ role: 'system', text }), [appendMessage]) - const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) - - const page = useCallback((text: string, title?: string) => { - const lines = text.split('\n') - patchOverlayState({ pager: { lines, offset: 0, title } }) - }, []) + const page = useCallback( + (text: string, title?: string) => patchOverlayState({ pager: { lines: text.split('\n'), offset: 0, title } }), + [] + ) const panel = useCallback( - (title: string, sections: PanelSection[]) => { - appendMessage({ role: 'system', text: '', kind: 'panel', panelData: { title, sections } }) - }, + (title: string, sections: PanelSection[]) => + appendMessage({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }), [appendMessage] ) const maybeWarn = useCallback( - (value: any) => { - if (value?.warning) { - sys(`warning: ${value.warning}`) + (value: unknown) => { + const warning = (value as { warning?: unknown } | null)?.warning + + if (typeof warning === 'string' && warning) { + sys(`warning: ${warning}`) } }, [sys] ) const maybeGoodVibes = useCallback((text: string) => { - if (!GOOD_VIBES_RE.test(text)) { - return + if (GOOD_VIBES_RE.test(text)) { + setGoodVibesTick(v => v + 1) } - - setGoodVibesTick(v => v + 1) - }, []) - - const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { - const display = cfg?.config?.display ?? {} - - setBellOnComplete(!!display?.bell_on_complete) - patchUiState({ - compact: !!display?.tui_compact, - detailsMode: resolveDetailsMode(display), - statusBar: display?.tui_statusbar !== false - }) }, []) const rpc: GatewayRpc = useCallback( @@ -262,12 +260,35 @@ export function useMainApp(gw: GatewayClient) { const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]) + const die = useCallback(() => { + gw.kill() + exit() + }, [exit, gw]) + + const session = useSessionLifecycle({ + colsRef, + composerActions, + gw, + rpc, + setHistoryItems, + setLastUserMsg, + setSessionStartedAt, + setStickyPrompt, + setVoiceProcessing, + setVoiceRecording, + sys + }) + + useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) + useEffect(() => { if (!ui.sid || !stdout) { return } - const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 }) + const onResize = () => + rpc('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid }) + stdout.on('resize', onResize) return () => { @@ -284,22 +305,21 @@ export function useMainApp(gw: GatewayClient) { } const label = toolTrailLabel('clarify') - const nextTrail = turnRefs.turnToolsRef.current.filter(line => !sameToolTrailGroup(label, line)) - turnRefs.turnToolsRef.current = nextTrail - turnActions.setTurnTrail(nextTrail) + turnController.turnTools = turnController.turnTools.filter(line => !sameToolTrailGroup(label, line)) + patchTurnState({ turnTrail: turnController.turnTools }) - rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { + rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { if (!r) { return } if (answer) { - turnRefs.persistedToolLabelsRef.current.add(label) + turnController.persistedToolLabels.add(label) appendMessage({ + kind: 'trail', role: 'system', text: '', - kind: 'trail', tools: [buildToolTrailLine('clarify', clarify.question)] }) appendMessage({ role: 'user', text: answer }) @@ -311,458 +331,43 @@ export function useMainApp(gw: GatewayClient) { patchOverlayState({ clarify: null }) }) }, - [appendMessage, overlay.clarify, rpc, sys, turnActions, turnRefs] - ) - - useEffect(() => { - if (!ui.sid) { - return - } - - rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled)) - rpc('config.get', { key: 'mtime' }).then(r => { - configMtimeRef.current = Number(r?.mtime ?? 0) - }) - rpc('config.get', { key: 'full' }).then(applyDisplayConfig) - }, [applyDisplayConfig, rpc, ui.sid]) - - useEffect(() => { - if (!ui.sid) { - return - } - - const id = setInterval(() => { - rpc('config.get', { key: 'mtime' }).then(r => { - const next = Number(r?.mtime ?? 0) - - if (configMtimeRef.current && next && next !== configMtimeRef.current) { - configMtimeRef.current = next - rpc('reload.mcp', { session_id: ui.sid }).then(r => { - if (!r) { - return - } - - turnActions.pushActivity('MCP reloaded after config change') - }) - rpc('config.get', { key: 'full' }).then(applyDisplayConfig) - } else if (!configMtimeRef.current && next) { - configMtimeRef.current = next - } - }) - }, 5000) - - return () => clearInterval(id) - }, [applyDisplayConfig, turnActions, rpc, ui.sid]) - - const idle = turnActions.idle - const clearReasoning = turnActions.clearReasoning - - const die = useCallback(() => { - gw.kill() - exit() - }, [exit, gw]) - - const resetSession = useCallback(() => { - idle() - clearReasoning() - setVoiceRecording(false) - setVoiceProcessing(false) - patchUiState({ - bgTasks: new Set(), - info: null, - sid: null, - usage: ZERO - }) - setHistoryItems([]) - setLastUserMsg('') - setStickyPrompt('') - composerActions.setPasteSnips([]) - turnActions.setActivity([]) - turnRefs.turnToolsRef.current = [] - turnRefs.lastStatusNoteRef.current = '' - turnRefs.protocolWarnedRef.current = false - turnRefs.persistedToolLabelsRef.current.clear() - }, [clearReasoning, composerActions, idle, turnActions, turnRefs]) - - const resetVisibleHistory = useCallback( - (info: SessionInfo | null = null) => { - idle() - clearReasoning() - setHistoryItems(info ? [introMsg(info)] : []) - patchUiState({ - info, - usage: info?.usage ? { ...ZERO, ...info.usage } : ZERO - }) - setStickyPrompt('') - composerActions.setPasteSnips([]) - turnActions.setActivity([]) - setLastUserMsg('') - turnRefs.turnToolsRef.current = [] - turnRefs.persistedToolLabelsRef.current.clear() - }, - [clearReasoning, composerActions, idle, turnActions, turnRefs] - ) - - const trimLastExchange = useCallback((items: Msg[]) => { - const q = [...items] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - if (q.at(-1)?.role === 'user') { - q.pop() - } - - return q - }, []) - - const guardBusySessionSwitch = useCallback( - (what = 'switch sessions') => { - if (!getUiState().busy) { - return false - } - - sys(`interrupt the current turn before trying to ${what}`) - - return true - }, - [sys] - ) - - const closeSession = useCallback( - (targetSid?: string | null) => { - if (!targetSid) { - return Promise.resolve(null) - } - - return rpc('session.close', { session_id: targetSid }) - }, - [rpc] - ) - - const newSession = useCallback( - async (msg?: string) => { - await closeSession(getUiState().sid) - - return rpc('session.create', { cols: colsRef.current }).then(r => { - if (!r) { - patchUiState({ status: 'ready' }) - - return - } - - resetSession() - setSessionStartedAt(Date.now()) - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO - }) - - if (r.info) { - setHistoryItems([introMsg(r.info)]) - } - - if (r.info?.credential_warning) { - sys(`warning: ${r.info.credential_warning}`) - } - - if (msg) { - sys(msg) - } - }) - }, - [closeSession, resetSession, rpc, sys] - ) - - const resumeById = useCallback( - (id: string) => { - patchOverlayState({ picker: false }) - patchUiState({ status: 'resuming…' }) - closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => - gw - .request('session.resume', { cols: colsRef.current, session_id: id }) - .then((raw: any) => { - const r = asRpcResult(raw) - - if (!r) { - sys('error: invalid response: session.resume') - patchUiState({ status: 'ready' }) - - return - } - - resetSession() - setSessionStartedAt(Date.now()) - const resumed = toTranscriptMessages(r.messages) - - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO - }) - }) - .catch((e: Error) => { - sys(`error: ${e.message}`) - patchUiState({ status: 'ready' }) - }) - ) - }, - [closeSession, gw, resetSession, sys] + [appendMessage, overlay.clarify, rpc, sys] ) const paste = useCallback( (quiet = false) => - rpc('clipboard.paste', { session_id: getUiState().sid }).then((r: any) => { + rpc('clipboard.paste', { session_id: getUiState().sid }).then(r => { if (!r) { return } if (r.attached) { const meta = imageTokenMeta(r) - sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) - return + return sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) } - quiet || sys(r.message || 'No image found in clipboard') + if (!quiet) { + sys(r.message || 'No image found in clipboard') + } }), [rpc, sys] ) clipboardPasteRef.current = paste - const handleTextPaste = composerActions.handleTextPaste - const send = useCallback( - (text: string) => { - const expandPasteSnips = (value: string) => { - const byLabel = new Map() - - for (const item of composerState.pasteSnips) { - const list = byLabel.get(item.label) - list ? list.push(item.text) : byLabel.set(item.label, [item.text]) - } - - return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token) - } - - const startSubmit = (displayText: string, submitText: string) => { - const sid = getUiState().sid - - if (!sid) { - sys('session not ready yet') - - return - } - - if (turnRefs.statusTimerRef.current) { - clearTimeout(turnRefs.statusTimerRef.current) - turnRefs.statusTimerRef.current = null - } - - maybeGoodVibes(submitText) - setLastUserMsg(text) - appendMessage({ role: 'user', text: displayText }) - patchUiState({ busy: true, status: 'running…' }) - turnRefs.bufRef.current = '' - turnRefs.interruptedRef.current = false - - gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { - sys(`error: ${e.message}`) - patchUiState({ busy: false, status: 'ready' }) - }) - } - - const sid = getUiState().sid - - if (!sid) { - sys('session not ready yet') - - return - } - - gw.request('input.detect_drop', { session_id: sid, text }) - .then((r: any) => { - if (r?.matched) { - if (r.is_image) { - const meta = imageTokenMeta(r) - turnActions.pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) - } else { - turnActions.pushActivity(`detected file: ${r.name}`) - } - - startSubmit(r.text || text, expandPasteSnips(r.text || text)) - - return - } - - startSubmit(text, expandPasteSnips(text)) - }) - .catch(() => startSubmit(text, expandPasteSnips(text))) - }, - [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, turnActions, sys, turnRefs] - ) - - const shellExec = useCallback( - (cmd: string) => { - appendMessage({ role: 'user', text: `!${cmd}` }) - patchUiState({ busy: true, status: 'running…' }) - - gw.request('shell.exec', { command: cmd }) - .then((raw: any) => { - const r = asRpcResult(raw) - - if (!r) { - sys('error: invalid response: shell.exec') - - return - } - - const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() - - if (out) { - sys(out) - } - - if (r.code !== 0 || !out) { - sys(`exit ${r.code}`) - } - }) - .catch((e: Error) => sys(`error: ${e.message}`)) - .finally(() => { - patchUiState({ busy: false, status: 'ready' }) - }) - }, - [appendMessage, gw, sys] - ) - - const openEditor = composerActions.openEditor - - const interpolate = useCallback( - (text: string, then: (result: string) => void) => { - patchUiState({ status: 'interpolating…' }) - const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] - - Promise.all( - matches.map(m => - gw - .request('shell.exec', { command: m[1]! }) - .then((raw: any) => { - const r = asRpcResult(raw) - - return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim() - }) - .catch(() => '(error)') - ) - ).then(results => { - let out = text - - for (let i = matches.length - 1; i >= 0; i--) { - out = out.slice(0, matches[i]!.index!) + results[i] + out.slice(matches[i]!.index! + matches[i]![0].length) - } - - then(out) - }) - }, - [gw] - ) - - const sendQueued = useCallback( - (text: string) => { - if (text.startsWith('!')) { - shellExec(text.slice(1).trim()) - - return - } - - if (hasInterpolation(text)) { - patchUiState({ busy: true }) - interpolate(text, send) - - return - } - - send(text) - }, - [interpolate, send, shellExec] - ) - - const dispatchSubmission = useCallback( - (full: string) => { - const live = getUiState() - - if (!full.trim()) { - return - } - - if (!live.sid) { - sys('session not ready yet') - - return - } - - if (looksLikeSlashCommand(full)) { - appendMessage({ role: 'system', text: full, kind: 'slash' }) - composerActions.pushHistory(full) - slashRef.current(full) - composerActions.clearIn() - - return - } - - if (full.startsWith('!')) { - composerActions.clearIn() - shellExec(full.slice(1).trim()) - - return - } - - const editIdx = composerRefs.queueEditRef.current - composerActions.clearIn() - - if (editIdx !== null) { - composerActions.replaceQueue(editIdx, full) - const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0] - composerActions.syncQueue() - composerActions.setQueueEdit(null) - - if (picked && getUiState().busy && live.sid) { - composerRefs.queueRef.current.unshift(picked) - composerActions.syncQueue() - - return - } - - if (picked && live.sid) { - sendQueued(picked) - } - - return - } - - composerActions.pushHistory(full) - - if (getUiState().busy) { - composerActions.enqueue(full) - - return - } - - if (hasInterpolation(full)) { - patchUiState({ busy: true }) - interpolate(full, send) - - return - } - - send(full) - }, - [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, sys] - ) + const { dispatchSubmission, send, sendQueued, shellExec, submit } = useSubmission({ + appendMessage, + composerActions, + composerRefs, + composerState, + gw, + maybeGoodVibes, + setLastUserMsg, + slashRef, + submitRef, + sys + }) const { pagerPageSize } = useInputHandlers({ actions: { @@ -770,109 +375,43 @@ export function useMainApp(gw: GatewayClient) { appendMessage, die, dispatchSubmission, - guardBusySessionSwitch, - newSession, + guardBusySessionSwitch: session.guardBusySessionSwitch, + newSession: session.newSession, sys }, - composer: { - actions: composerActions, - refs: composerRefs, - state: composerState - }, + composer: { actions: composerActions, refs: composerRefs, state: composerState }, gateway, - terminal: { - hasSelection, - scrollRef, - scrollWithSelection, - selection, - stdout - }, - turn: { - actions: turnActions, - refs: turnRefs - }, - voice: { - recording: voiceRecording, - setProcessing: setVoiceProcessing, - setRecording: setVoiceRecording - }, + terminal: { hasSelection, scrollRef, scrollWithSelection, selection, stdout }, + voice: { recording: voiceRecording, setProcessing: setVoiceProcessing, setRecording: setVoiceRecording }, wheelStep: WHEEL_SCROLL_STEP }) const onEvent = useMemo( () => createGatewayEventHandler({ - composer: { - dequeue: composerActions.dequeue, - queueEditRef: composerRefs.queueEditRef, - sendQueued - }, + composer: { dequeue: composerActions.dequeue, queueEditRef: composerRefs.queueEditRef, sendQueued }, gateway, session: { STARTUP_RESUME_ID, colsRef, - newSession, - resetSession, + newSession: session.newSession, + resetSession: session.resetSession, setCatalog }, - system: { - bellOnComplete, - stdout, - sys - }, - transcript: { - appendMessage, - setHistoryItems - }, - turn: { - actions: { - clearReasoning, - endReasoningPhase: turnActions.endReasoningPhase, - idle, - pruneTransient: turnActions.pruneTransient, - pulseReasoningStreaming: turnActions.pulseReasoningStreaming, - pushActivity: turnActions.pushActivity, - pushTrail: turnActions.pushTrail, - scheduleReasoning: turnActions.scheduleReasoning, - scheduleStreaming: turnActions.scheduleStreaming, - setActivity: turnActions.setActivity, - setReasoningTokens: turnActions.setReasoningTokens, - setStreaming: turnActions.setStreaming, - setSubagents: turnActions.setSubagents, - setToolTokens: turnActions.setToolTokens, - setTools: turnActions.setTools, - setTurnTrail: turnActions.setTurnTrail - }, - refs: { - activeToolsRef: turnRefs.activeToolsRef, - bufRef: turnRefs.bufRef, - interruptedRef: turnRefs.interruptedRef, - lastStatusNoteRef: turnRefs.lastStatusNoteRef, - persistedToolLabelsRef: turnRefs.persistedToolLabelsRef, - protocolWarnedRef: turnRefs.protocolWarnedRef, - reasoningRef: turnRefs.reasoningRef, - statusTimerRef: turnRefs.statusTimerRef, - toolTokenAccRef: turnRefs.toolTokenAccRef, - toolCompleteRibbonRef: turnRefs.toolCompleteRibbonRef, - turnToolsRef: turnRefs.turnToolsRef - } - } + system: { bellOnComplete, stdout, sys }, + transcript: { appendMessage, setHistoryItems } }), [ appendMessage, bellOnComplete, - clearReasoning, composerActions, composerRefs, gateway, - idle, - newSession, - resetSession, sendQueued, - sys, - turnActions, - turnRefs, - stdout + session.newSession, + session.resetSession, + stdout, + sys ] ) @@ -883,7 +422,7 @@ export function useMainApp(gw: GatewayClient) { const exitHandler = () => { patchUiState({ busy: false, sid: null, status: 'gateway exited' }) - turnActions.pushActivity('gateway exited · /logs to inspect', 'error') + turnController.pushActivity('gateway exited · /logs to inspect', 'error') sys('error: gateway exited') } @@ -896,14 +435,13 @@ export function useMainApp(gw: GatewayClient) { gw.off('exit', exitHandler) gw.kill() } - }, [gw, turnActions, sys]) + }, [gw, sys]) - useLongRunToolCharms(ui.busy, turnState.tools, turnActions.pushActivity) + useLongRunToolCharms(ui.busy, turn.tools) const slash = useMemo( () => createSlashHandler({ - slashFlightRef, composer: { enqueue: composerActions.enqueue, hasSelection, @@ -920,163 +458,51 @@ export function useMainApp(gw: GatewayClient) { maybeWarn }, session: { - closeSession, + closeSession: session.closeSession, die, - guardBusySessionSwitch, - newSession, - resetVisibleHistory, - resumeById, + guardBusySessionSwitch: session.guardBusySessionSwitch, + newSession: session.newSession, + resetVisibleHistory: session.resetVisibleHistory, + resumeById: session.resumeById, setSessionStartedAt }, - transcript: { - page, - panel, - send, - setHistoryItems, - sys, - trimLastExchange - }, - voice: { - setVoiceEnabled - } + slashFlightRef, + transcript: { page, panel, send, setHistoryItems, sys, trimLastExchange: session.trimLastExchange }, + voice: { setVoiceEnabled } }), [ catalog, - closeSession, composerActions, composerRefs, die, gateway, - slashFlightRef, - guardBusySessionSwitch, hasSelection, maybeWarn, - newSession, page, panel, paste, - resetVisibleHistory, - resumeById, selection, send, - setSessionStartedAt, - setHistoryItems, - setVoiceEnabled, - sys, - trimLastExchange + session, + sys ] ) slashRef.current = slash - const submit = useCallback( - (value: string) => { - if (value.startsWith('/') && composerState.completions.length) { - const row = composerState.completions[composerState.compIdx] - - if (row?.text) { - const text = - value.startsWith('/') && row.text.startsWith('/') && composerState.compReplace > 0 - ? row.text.slice(1) - : row.text - - const next = value.slice(0, composerState.compReplace) + text - - if (next !== value) { - composerActions.setInput(next) - - return - } - } - } - - if (!value.trim() && !composerState.inputBuf.length) { - const live = getUiState() - const now = Date.now() - const dbl = now - lastEmptyAt.current < 450 - lastEmptyAt.current = now - - if (dbl && live.busy && live.sid) { - turnActions.interruptTurn({ - appendMessage, - gw, - sid: live.sid, - sys - }) - - return - } - - if (dbl && composerRefs.queueRef.current.length) { - const next = composerActions.dequeue() - - if (next && live.sid) { - composerActions.setQueueEdit(null) - dispatchSubmission(next) - } - } - - return - } - - lastEmptyAt.current = 0 - - if (value.endsWith('\\')) { - composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)]) - composerActions.setInput('') - - return - } - - dispatchSubmission([...composerState.inputBuf, value].join('\n')) - }, - [appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys, turnActions] + const respondWith = useCallback( + (method: string, params: Record, done: () => void) => rpc(method, params).then(r => r && done()), + [rpc] ) - submitRef.current = submit - - const statusColor = - ui.status === 'ready' - ? ui.theme.color.ok - : ui.status.startsWith('error') - ? ui.theme.color.error - : ui.status === 'interrupted' - ? ui.theme.color.warn - : ui.theme.color.dim - - const sessionStarted = ui.sid ? sessionStartedAt : null - const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` - const cwdLabel = shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd()) - const showStreamingArea = Boolean(turnState.streaming) - const showStickyPrompt = !!stickyPrompt - - const hasReasoning = Boolean(turnState.reasoning.trim()) - - const showProgressArea = - ui.detailsMode === 'hidden' - ? turnState.activity.some(item => item.tone !== 'info') - : Boolean( - ui.busy || - turnState.subagents.length || - turnState.tools.length || - turnState.turnTrail.length || - hasReasoning || - turnState.activity.length - ) - const answerApproval = useCallback( - (choice: string) => { - rpc('approval.respond', { choice, session_id: ui.sid }).then(r => { - if (!r) { - return - } - + (choice: string) => + respondWith('approval.respond', { choice, session_id: ui.sid }, () => { patchOverlayState({ approval: null }) sys(choice === 'deny' ? 'denied' : `approved (${choice})`) patchUiState({ status: 'running…' }) - }) - }, - [rpc, sys, ui.sid] + }), + [respondWith, sys, ui.sid] ) const answerSudo = useCallback( @@ -1085,16 +511,12 @@ export function useMainApp(gw: GatewayClient) { return } - rpc('sudo.respond', { request_id: overlay.sudo.requestId, password: pw }).then(r => { - if (!r) { - return - } - + return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => { patchOverlayState({ sudo: null }) patchUiState({ status: 'running…' }) }) }, - [overlay.sudo, rpc] + [overlay.sudo, respondWith] ) const answerSecret = useCallback( @@ -1103,16 +525,12 @@ export function useMainApp(gw: GatewayClient) { return } - rpc('secret.respond', { request_id: overlay.secret.requestId, value }).then(r => { - if (!r) { - return - } - + return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => { patchOverlayState({ secret: null }) patchUiState({ status: 'running…' }) }) }, - [overlay.secret, rpc] + [overlay.secret, respondWith] ) const onModelSelect = useCallback((value: string) => { @@ -1120,6 +538,20 @@ export function useMainApp(gw: GatewayClient) { slashRef.current(`/model ${value}`) }, []) + const hasReasoning = Boolean(turn.reasoning.trim()) + + const showProgressArea = + ui.detailsMode === 'hidden' + ? turn.activity.some(item => item.tone !== 'info') + : Boolean( + ui.busy || + turn.subagents.length || + turn.tools.length || + turn.turnTrail.length || + hasReasoning || + turn.activity.length + ) + const appActions = useMemo( () => ({ answerApproval, @@ -1127,10 +559,10 @@ export function useMainApp(gw: GatewayClient) { answerSecret, answerSudo, onModelSelect, - resumeById, + resumeById: session.resumeById, setStickyPrompt }), - [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, resumeById, setStickyPrompt] + [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, session.resumeById] ) const appComposer = useMemo( @@ -1139,7 +571,7 @@ export function useMainApp(gw: GatewayClient) { compIdx: composerState.compIdx, completions: composerState.completions, empty, - handleTextPaste, + handleTextPaste: composerActions.handleTextPaste, input: composerState.input, inputBuf: composerState.inputBuf, pagerPageSize, @@ -1148,69 +580,31 @@ export function useMainApp(gw: GatewayClient) { submit, updateInput: composerActions.setInput }), - [ - cols, - composerActions.setInput, - composerState.compIdx, - composerState.completions, - composerState.input, - composerState.inputBuf, - composerState.queueEditIdx, - composerState.queuedDisplay, - empty, - handleTextPaste, - pagerPageSize, - submit - ] + [cols, composerActions, composerState, empty, pagerPageSize, submit] ) const appProgress = useMemo( - () => ({ - activity: turnState.activity, - reasoning: turnState.reasoning, - reasoningActive: turnState.reasoningActive, - reasoningStreaming: turnState.reasoningStreaming, - reasoningTokens: turnState.reasoningTokens, - showProgressArea, - showStreamingArea, - streaming: turnState.streaming, - subagents: turnState.subagents, - toolTokens: turnState.toolTokens, - tools: turnState.tools, - turnTrail: turnState.turnTrail - }), - [showProgressArea, showStreamingArea, turnState] + () => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }), + [turn, showProgressArea] ) const appStatus = useMemo( () => ({ - cwdLabel, + cwdLabel: shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd()), goodVibesTick, - sessionStartedAt: sessionStarted, - showStickyPrompt, - statusColor, + sessionStartedAt: ui.sid ? sessionStartedAt : null, + showStickyPrompt: !!stickyPrompt, + statusColor: statusColorOf(ui.status, ui.theme.color), stickyPrompt, - voiceLabel + voiceLabel: voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` }), - [cwdLabel, goodVibesTick, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] + [goodVibesTick, sessionStartedAt, stickyPrompt, ui, voiceEnabled, voiceProcessing, voiceRecording] ) const appTranscript = useMemo( - () => ({ - historyItems, - scrollRef, - virtualHistory, - virtualRows - }), - [historyItems, scrollRef, virtualHistory, virtualRows] + () => ({ historyItems, scrollRef, virtualHistory, virtualRows }), + [historyItems, virtualHistory, virtualRows] ) - return { - appActions, - appComposer, - appProgress, - appStatus, - appTranscript, - gateway - } + return { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } } diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts new file mode 100644 index 0000000000..bbde757cf2 --- /dev/null +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -0,0 +1,185 @@ +import { useCallback } from 'react' + +import { introMsg, toTranscriptMessages } from '../domain/messages.js' +import { ZERO } from '../domain/usage.js' +import { type GatewayClient } from '../gatewayClient.js' +import type { SessionCloseResponse, SessionCreateResponse, SessionResumeResponse } from '../gatewayTypes.js' +import { asRpcResult } from '../lib/rpc.js' +import type { Msg, SessionInfo, Usage } from '../types.js' + +import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js' +import { patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' +import { patchTurnState } from './turnStore.js' +import { getUiState, patchUiState } from './uiStore.js' + +const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO) + +const trimTail = (items: Msg[]) => { + const q = [...items] + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + + if (q.at(-1)?.role === 'user') { + q.pop() + } + + return q +} + +export interface UseSessionLifecycleOptions { + colsRef: { current: number } + composerActions: ComposerActions + gw: GatewayClient + rpc: GatewayRpc + setHistoryItems: StateSetter + setLastUserMsg: StateSetter + setSessionStartedAt: StateSetter + setStickyPrompt: StateSetter + setVoiceProcessing: StateSetter + setVoiceRecording: StateSetter + sys: (text: string) => void +} + +export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { + const { + colsRef, + composerActions, + gw, + rpc, + setHistoryItems, + setLastUserMsg, + setSessionStartedAt, + setStickyPrompt, + setVoiceProcessing, + setVoiceRecording, + sys + } = opts + + const closeSession = useCallback( + (targetSid?: null | string) => + targetSid ? rpc('session.close', { session_id: targetSid }) : Promise.resolve(null), + [rpc] + ) + + const resetSession = useCallback(() => { + turnController.fullReset() + setVoiceRecording(false) + setVoiceProcessing(false) + patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO }) + setHistoryItems([]) + setLastUserMsg('') + setStickyPrompt('') + composerActions.setPasteSnips([]) + }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]) + + const resetVisibleHistory = useCallback( + (info: null | SessionInfo = null) => { + turnController.idle() + turnController.clearReasoning() + turnController.turnTools = [] + turnController.persistedToolLabels.clear() + + setHistoryItems(info ? [introMsg(info)] : []) + setStickyPrompt('') + setLastUserMsg('') + composerActions.setPasteSnips([]) + patchTurnState({ activity: [] }) + patchUiState({ info, usage: usageFrom(info) }) + }, + [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt] + ) + + const newSession = useCallback( + async (msg?: string) => { + await closeSession(getUiState().sid) + + const r = await rpc('session.create', { cols: colsRef.current }) + + if (!r) { + return patchUiState({ status: 'ready' }) + } + + resetSession() + setSessionStartedAt(Date.now()) + patchUiState({ info: r.info ?? null, sid: r.session_id, status: 'ready', usage: usageFrom(r.info ?? null) }) + + if (r.info) { + setHistoryItems([introMsg(r.info)]) + } + + if (r.info?.credential_warning) { + sys(`warning: ${r.info.credential_warning}`) + } + + if (msg) { + sys(msg) + } + }, + [closeSession, colsRef, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] + ) + + const resumeById = useCallback( + (id: string) => { + patchOverlayState({ picker: false }) + patchUiState({ status: 'resuming…' }) + + closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => + gw + .request('session.resume', { cols: colsRef.current, session_id: id }) + .then(raw => { + const r = asRpcResult(raw) + + if (!r) { + sys('error: invalid response: session.resume') + + return patchUiState({ status: 'ready' }) + } + + resetSession() + setSessionStartedAt(Date.now()) + + const resumed = toTranscriptMessages(r.messages) + + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: usageFrom(r.info ?? null) + }) + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ status: 'ready' }) + }) + ) + }, + [closeSession, colsRef, gw, resetSession, setHistoryItems, setSessionStartedAt, sys] + ) + + const guardBusySessionSwitch = useCallback( + (what = 'switch sessions') => { + if (!getUiState().busy) { + return false + } + + sys(`interrupt the current turn before trying to ${what}`) + + return true + }, + [sys] + ) + + return { + closeSession, + guardBusySessionSwitch, + newSession, + resetSession, + resetVisibleHistory, + resumeById, + trimLastExchange: trimTail + } +} diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts new file mode 100644 index 0000000000..4fc699676e --- /dev/null +++ b/ui-tui/src/app/useSubmission.ts @@ -0,0 +1,300 @@ +import { type MutableRefObject, useCallback, useRef } from 'react' + +import { imageTokenMeta } from '../domain/messages.js' +import { looksLikeSlashCommand } from '../domain/slash.js' +import type { GatewayClient } from '../gatewayClient.js' +import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' +import { asRpcResult } from '../lib/rpc.js' +import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' +import { PASTE_SNIPPET_RE } from '../protocol/paste.js' +import type { Msg } from '../types.js' + +import type { ComposerActions, ComposerRefs, ComposerState, PasteSnippet } from './interfaces.js' +import { turnController } from './turnController.js' +import { getUiState, patchUiState } from './uiStore.js' + +const DOUBLE_ENTER_MS = 450 + +const expandSnips = (snips: PasteSnippet[]) => { + const byLabel = new Map() + + for (const { label, text } of snips) { + const hit = byLabel.get(label) + hit ? hit.push(text) : byLabel.set(label, [text]) + } + + return (value: string) => value.replace(PASTE_SNIPPET_RE, tok => byLabel.get(tok)?.shift() ?? tok) +} + +const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) => + matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text) + +export interface UseSubmissionOptions { + appendMessage: (msg: Msg) => void + composerActions: ComposerActions + composerRefs: ComposerRefs + composerState: ComposerState + gw: GatewayClient + maybeGoodVibes: (text: string) => void + setLastUserMsg: (value: string) => void + slashRef: MutableRefObject<(cmd: string) => boolean> + submitRef: MutableRefObject<(value: string) => void> + sys: (text: string) => void +} + +export function useSubmission(opts: UseSubmissionOptions) { + const { + appendMessage, + composerActions, + composerRefs, + composerState, + gw, + maybeGoodVibes, + setLastUserMsg, + slashRef, + submitRef, + sys + } = opts + + const lastEmptyAt = useRef(0) + + const send = useCallback( + (text: string) => { + const expand = expandSnips(composerState.pasteSnips) + + const startSubmit = (displayText: string, submitText: string) => { + const sid = getUiState().sid + + if (!sid) { + return sys('session not ready yet') + } + + turnController.clearStatusTimer() + maybeGoodVibes(submitText) + setLastUserMsg(text) + appendMessage({ role: 'user', text: displayText }) + patchUiState({ busy: true, status: 'running…' }) + turnController.bufRef = '' + turnController.interrupted = false + + gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ busy: false, status: 'ready' }) + }) + } + + const sid = getUiState().sid + + if (!sid) { + return sys('session not ready yet') + } + + gw.request('input.detect_drop', { session_id: sid, text }) + .then(r => { + if (!r?.matched) { + return startSubmit(text, expand(text)) + } + + if (r.is_image) { + const meta = imageTokenMeta(r) + + turnController.pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) + } else { + turnController.pushActivity(`detected file: ${r.name}`) + } + + startSubmit(r.text || text, expand(r.text || text)) + }) + .catch(() => startSubmit(text, expand(text))) + }, + [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys] + ) + + const shellExec = useCallback( + (cmd: string) => { + appendMessage({ role: 'user', text: `!${cmd}` }) + patchUiState({ busy: true, status: 'running…' }) + + gw.request('shell.exec', { command: cmd }) + .then(raw => { + const r = asRpcResult(raw) + + if (!r) { + return sys('error: invalid response: shell.exec') + } + + const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() + + if (out) { + sys(out) + } + + if (r.code !== 0 || !out) { + sys(`exit ${r.code}`) + } + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + .finally(() => patchUiState({ busy: false, status: 'ready' })) + }, + [appendMessage, gw, sys] + ) + + const interpolate = useCallback( + (text: string, then: (result: string) => void) => { + patchUiState({ status: 'interpolating…' }) + const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] + + Promise.all( + matches.map(m => + gw + .request('shell.exec', { command: m[1]! }) + .then(raw => { + const r = asRpcResult(raw) + + return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim() + }) + .catch(() => '(error)') + ) + ).then(results => then(spliceMatches(text, matches, results))) + }, + [gw] + ) + + const sendQueued = useCallback( + (text: string) => { + if (text.startsWith('!')) { + return shellExec(text.slice(1).trim()) + } + + if (hasInterpolation(text)) { + patchUiState({ busy: true }) + + return interpolate(text, send) + } + + send(text) + }, + [interpolate, send, shellExec] + ) + + const dispatchSubmission = useCallback( + (full: string) => { + if (!full.trim()) { + return + } + + const live = getUiState() + + if (!live.sid) { + return sys('session not ready yet') + } + + if (looksLikeSlashCommand(full)) { + appendMessage({ kind: 'slash', role: 'system', text: full }) + composerActions.pushHistory(full) + slashRef.current(full) + composerActions.clearIn() + + return + } + + if (full.startsWith('!')) { + composerActions.clearIn() + + return shellExec(full.slice(1).trim()) + } + + const editIdx = composerRefs.queueEditRef.current + composerActions.clearIn() + + if (editIdx !== null) { + composerActions.replaceQueue(editIdx, full) + const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0] + composerActions.syncQueue() + composerActions.setQueueEdit(null) + + if (!picked || !live.sid) { + return + } + + if (getUiState().busy) { + composerRefs.queueRef.current.unshift(picked) + + return composerActions.syncQueue() + } + + return sendQueued(picked) + } + + composerActions.pushHistory(full) + + if (getUiState().busy) { + return composerActions.enqueue(full) + } + + if (hasInterpolation(full)) { + patchUiState({ busy: true }) + + return interpolate(full, send) + } + + send(full) + }, + [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef, sys] + ) + + const submit = useCallback( + (value: string) => { + if (value.startsWith('/') && composerState.completions.length) { + const row = composerState.completions[composerState.compIdx] + + if (row?.text) { + const text = row.text.startsWith('/') && composerState.compReplace > 0 ? row.text.slice(1) : row.text + + const next = value.slice(0, composerState.compReplace) + text + + if (next !== value) { + return composerActions.setInput(next) + } + } + } + + if (!value.trim() && !composerState.inputBuf.length) { + const live = getUiState() + const now = Date.now() + const doubleTap = now - lastEmptyAt.current < DOUBLE_ENTER_MS + lastEmptyAt.current = now + + if (doubleTap && live.busy && live.sid) { + return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }) + } + + if (doubleTap && composerRefs.queueRef.current.length) { + const next = composerActions.dequeue() + + if (next && live.sid) { + composerActions.setQueueEdit(null) + dispatchSubmission(next) + } + } + + return + } + + lastEmptyAt.current = 0 + + if (value.endsWith('\\')) { + composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)]) + + return composerActions.setInput('') + } + + dispatchSubmission([...composerState.inputBuf, value].join('\n')) + }, + [appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys] + ) + + submitRef.current = submit + + return { dispatchSubmission, send, sendQueued, shellExec, submit } +} diff --git a/ui-tui/src/app/useTurnState.ts b/ui-tui/src/app/useTurnState.ts deleted file mode 100644 index c927773112..0000000000 --- a/ui-tui/src/app/useTurnState.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' - -import { estimateTokensRough, isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js' -import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' - -import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js' -import type { InterruptTurnOptions, ToolCompleteRibbon, UseTurnStateResult } from './interfaces.js' -import { resetOverlayState } from './overlayStore.js' -import { patchUiState } from './uiStore.js' - -export function useTurnState(): UseTurnStateResult { - const [activity, setActivity] = useState([]) - const [reasoning, setReasoning] = useState('') - const [reasoningTokens, setReasoningTokens] = useState(0) - const [reasoningActive, setReasoningActive] = useState(false) - const [toolTokens, setToolTokens] = useState(0) - const [reasoningStreaming, setReasoningStreaming] = useState(false) - const [streaming, setStreaming] = useState('') - const [subagents, setSubagents] = useState([]) - const [tools, setTools] = useState([]) - const [turnTrail, setTurnTrail] = useState([]) - - const activityIdRef = useRef(0) - const activeToolsRef = useRef([]) - const bufRef = useRef('') - const interruptedRef = useRef(false) - const lastStatusNoteRef = useRef('') - const persistedToolLabelsRef = useRef>(new Set()) - const protocolWarnedRef = useRef(false) - const reasoningRef = useRef('') - const reasoningStreamingTimerRef = useRef | null>(null) - const reasoningTimerRef = useRef | null>(null) - const statusTimerRef = useRef | null>(null) - const streamTimerRef = useRef | null>(null) - const toolTokenAccRef = useRef(0) - const toolCompleteRibbonRef = useRef(null) - const turnToolsRef = useRef([]) - - const setTrail = (next: string[]) => { - turnToolsRef.current = next - - return next - } - - const pulseReasoningStreaming = useCallback(() => { - if (reasoningStreamingTimerRef.current) { - clearTimeout(reasoningStreamingTimerRef.current) - } - - setReasoningActive(true) - setReasoningStreaming(true) - reasoningStreamingTimerRef.current = setTimeout(() => { - reasoningStreamingTimerRef.current = null - setReasoningStreaming(false) - }, REASONING_PULSE_MS) - }, []) - - const scheduleStreaming = useCallback(() => { - if (streamTimerRef.current) { - return - } - - streamTimerRef.current = setTimeout(() => { - streamTimerRef.current = null - setStreaming(bufRef.current.trimStart()) - }, STREAM_BATCH_MS) - }, []) - - const scheduleReasoning = useCallback(() => { - if (reasoningTimerRef.current) { - return - } - - reasoningTimerRef.current = setTimeout(() => { - reasoningTimerRef.current = null - setReasoning(reasoningRef.current) - setReasoningTokens(estimateTokensRough(reasoningRef.current)) - }, STREAM_BATCH_MS) - }, []) - - const endReasoningPhase = useCallback(() => { - if (reasoningStreamingTimerRef.current) { - clearTimeout(reasoningStreamingTimerRef.current) - reasoningStreamingTimerRef.current = null - } - - setReasoningStreaming(false) - setReasoningActive(false) - }, []) - - useEffect( - () => () => { - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current) - } - - if (reasoningTimerRef.current) { - clearTimeout(reasoningTimerRef.current) - } - - if (reasoningStreamingTimerRef.current) { - clearTimeout(reasoningStreamingTimerRef.current) - } - }, - [] - ) - - const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { - setActivity(prev => { - const base = replaceLabel ? prev.filter(item => !sameToolTrailGroup(replaceLabel, item.text)) : prev - - if (base.at(-1)?.text === text && base.at(-1)?.tone === tone) { - return base - } - - activityIdRef.current++ - - return [...base, { id: activityIdRef.current, text, tone }].slice(-8) - }) - }, []) - - const pruneTransient = useCallback(() => { - setTurnTrail(prev => { - const next = prev.filter(line => !isTransientTrailLine(line)) - - return next.length === prev.length ? prev : setTrail(next) - }) - }, []) - - const pushTrail = useCallback((line: string) => { - setTurnTrail(prev => - prev.at(-1) === line ? prev : setTrail([...prev.filter(item => !isTransientTrailLine(item)), line].slice(-8)) - ) - }, []) - - const clearReasoning = useCallback(() => { - if (reasoningTimerRef.current) { - clearTimeout(reasoningTimerRef.current) - reasoningTimerRef.current = null - } - - reasoningRef.current = '' - toolTokenAccRef.current = 0 - setReasoning('') - setReasoningTokens(0) - setToolTokens(0) - }, []) - - const idle = useCallback(() => { - endReasoningPhase() - activeToolsRef.current = [] - setSubagents([]) - setTools([]) - setTurnTrail([]) - patchUiState({ busy: false }) - resetOverlayState() - - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current) - streamTimerRef.current = null - } - - setStreaming('') - bufRef.current = '' - }, [endReasoningPhase]) - - const interruptTurn = useCallback( - ({ appendMessage, gw, sid, sys }: InterruptTurnOptions) => { - interruptedRef.current = true - gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - const partial = bufRef.current.trimStart() - - if (partial) { - appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) - } else { - sys('interrupted') - } - - idle() - clearReasoning() - setActivity([]) - turnToolsRef.current = [] - patchUiState({ status: 'interrupted' }) - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - } - - statusTimerRef.current = setTimeout(() => { - statusTimerRef.current = null - patchUiState({ status: 'ready' }) - }, 1500) - }, - [clearReasoning, idle] - ) - - const actions = useMemo( - () => ({ - clearReasoning, - endReasoningPhase, - idle, - interruptTurn, - pruneTransient, - pulseReasoningStreaming, - pushActivity, - pushTrail, - scheduleReasoning, - scheduleStreaming, - setActivity, - setReasoning, - setReasoningTokens, - setReasoningActive, - setToolTokens, - setReasoningStreaming, - setStreaming, - setSubagents, - setTools, - setTurnTrail - }), - [ - clearReasoning, - endReasoningPhase, - idle, - interruptTurn, - pruneTransient, - pulseReasoningStreaming, - pushActivity, - pushTrail, - scheduleReasoning, - scheduleStreaming - ] - ) - - const refs = useMemo( - () => ({ - activeToolsRef, - bufRef, - interruptedRef, - lastStatusNoteRef, - persistedToolLabelsRef, - protocolWarnedRef, - reasoningRef, - reasoningStreamingTimerRef, - reasoningTimerRef, - statusTimerRef, - streamTimerRef, - toolTokenAccRef, - toolCompleteRibbonRef, - turnToolsRef - }), - [] - ) - - const state = useMemo( - () => ({ - activity, - reasoning, - reasoningTokens, - reasoningActive, - toolTokens, - reasoningStreaming, - streaming, - subagents, - tools, - turnTrail - }), - [ - activity, - reasoning, - reasoningTokens, - reasoningActive, - toolTokens, - reasoningStreaming, - streaming, - subagents, - tools, - turnTrail - ] - ) - - return { - actions, - refs, - state - } -} diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index cad10f6489..4e55a53ba8 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,7 +1,8 @@ import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react' -import { fmtDuration, stickyPromptFromViewport } from '../app/helpers.js' +import { fmtDuration } from '../domain/messages.js' +import { stickyPromptFromViewport } from '../domain/viewport.js' import { fmtK } from '../lib/text.js' import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' @@ -66,7 +67,7 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { return () => clearTimeout(id) }, [t.color.amber, tick]) - return {active ? '♥' : ' '} + return {active ? '♥' : ' '} } export function StatusRule({ @@ -108,29 +109,29 @@ export function StatusRule({ return ( - + {'─ '} - {status} - │ {model} - {ctxLabel ? │ {ctxLabel} : null} + {status} + │ {model} + {ctxLabel ? │ {ctxLabel} : null} {bar ? ( - + {' │ '} - [{bar}] {pctLabel} + [{bar}] {pctLabel} ) : null} {sessionStartedAt ? ( - + {' │ '} ) : null} - {voiceLabel ? │ {voiceLabel} : null} - {bgCount > 0 ? │ {bgCount} bg : null} + {voiceLabel ? │ {voiceLabel} : null} + {bgCount > 0 ? │ {bgCount} bg : null} - - {cwdLabel} + + {cwdLabel} ) } @@ -139,7 +140,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri return ( {!scrollable ? ( - + {' \n'.repeat(Math.max(0, vp - 1))}{' '} ) : ( <> {thumbTop > 0 ? ( - + {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} ) : null} {thumb > 0 ? ( - {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} + {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} ) : null} {vp - thumbTop - thumb > 0 ? ( - + {`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`} ) : null} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 3d80e5fb1d..d517fa7a74 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -2,10 +2,10 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' import { memo } from 'react' -import { PLACEHOLDER } from '../app/constants.js' import type { AppLayoutProps } from '../app/interfaces.js' import { $isBlocked } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' +import { PLACEHOLDER } from '../content/placeholders.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { AppOverlays } from './appOverlays.js' @@ -119,14 +119,14 @@ const ComposerPane = memo(function ComposerPane({ /> {ui.bgTasks.size > 0 && ( - + {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running )} {status.showStickyPrompt ? ( - - + + {status.stickyPrompt} @@ -169,19 +169,19 @@ const ComposerPane = memo(function ComposerPane({ {composer.inputBuf.map((line, i) => ( - {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} + {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} - {line || ' '} + {line || ' '} ))} {sh ? ( - $ + $ ) : ( - + {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} )} @@ -204,7 +204,7 @@ const ComposerPane = memo(function ComposerPane({ )} - {!composer.empty && !ui.sid && ⚕ {ui.status}} + {!composer.empty && !ui.sid && ⚕ {ui.status}} ) }) diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 9b7f7b9dbf..75562eb9fd 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -112,7 +112,7 @@ export function AppOverlays({ {overlay.pager.title && ( - + {overlay.pager.title} @@ -123,7 +123,7 @@ export function AppOverlays({ ))} - + {overlay.pager.offset + pagerPageSize < overlay.pager.lines.length ? `Enter/Space for more · q to close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})` : `end · q to close (${overlay.pager.lines.length} lines)`} @@ -141,16 +141,16 @@ export function AppOverlays({ return ( - + {' '} {item.display} - {item.meta ? {item.meta} : null} + {item.meta ? {item.meta} : null} ) })} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 392b01c49a..541971a01f 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,8 +1,10 @@ import { Ansi, Box, NoSelect, Text } from '@hermes/ink' import { memo } from 'react' -import { LONG_MSG, ROLE } from '../constants.js' -import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js' +import { LONG_MSG } from '../config/limits.js' +import { userDisplay } from '../domain/messages.js' +import { ROLE } from '../domain/roles.js' +import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js' import type { Theme } from '../theme.js' import type { DetailsMode, Msg } from '../types.js' diff --git a/ui-tui/src/components/themed.tsx b/ui-tui/src/components/themed.tsx new file mode 100644 index 0000000000..b007d78aa0 --- /dev/null +++ b/ui-tui/src/components/themed.tsx @@ -0,0 +1,45 @@ +import { Text } from '@hermes/ink' +import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' + +import { $uiState } from '../app/uiStore.js' +import type { ThemeColors } from '../theme.js' + +export type ThemeColor = keyof ThemeColors + +export interface FgProps { + bold?: boolean + c?: ThemeColor + children?: ReactNode + dim?: boolean + italic?: boolean + literal?: string + strikethrough?: boolean + underline?: boolean + wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim' +} + +/** + * Theme-aware text. `literal` wins; otherwise `c` is a palette key. + * + * hi // amber + * // dim cornsilk + * x // raw hex + */ +export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) { + const { theme } = useStore($uiState) + + return ( + + {children} + + ) +} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index ef3a7aba0d..76dbefe579 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -2,6 +2,7 @@ import { Box, NoSelect, Text } from '@hermes/ink' import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' +import { THINKING_COT_MAX } from '../config/limits.js' import { compactPreview, estimateTokensRough, @@ -9,7 +10,6 @@ import { formatToolCall, parseToolTrailResultLine, pick, - THINKING_COT_MAX, thinkingPreview, toolTrailLabel } from '../lib/text.js' @@ -55,7 +55,7 @@ function TreeRow({ return ( - + {lead} @@ -84,11 +84,11 @@ function TreeTextRow({ wrap?: 'truncate-end' | 'wrap' | 'wrap-trim' }) { const text = dimColor ? ( - + {content} ) : ( - + {content} ) @@ -144,7 +144,7 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?: return () => clearInterval(id) }, [spin]) - return {spin.frames[frame]} + return {spin.frames[frame]} } interface DetailRow { @@ -195,11 +195,11 @@ function StreamCursor({ } return dimColor ? ( - + {streaming && on ? '▍' : ' '} ) : ( - {streaming && on ? '▍' : ' '} + {streaming && on ? '▍' : ' '} ) } @@ -224,12 +224,12 @@ function Chevron({ return ( onClick(!!e?.shiftKey || !!e?.ctrlKey)}> - - {open ? '▾ ' : '▸ '} + + {open ? '▾ ' : '▸ '} {title} {typeof count === 'number' ? ` (${count})` : ''} {suffix ? ( - + {' '} {suffix} @@ -366,7 +366,7 @@ function SubagentAccordion({ color={t.color.cornsilk} content={ <> - + {line} } @@ -501,7 +501,7 @@ export const Thinking = memo(function Thinking({ {preview ? ( mode === 'full' ? ( lines.map((line, index) => ( - + {line || ' '} {index === lines.length - 1 ? ( @@ -509,13 +509,13 @@ export const Thinking = memo(function Thinking({ )) ) : ( - + {preview} ) ) : ( - + )} @@ -715,7 +715,7 @@ export const ToolTrail = memo(function ToolTrail({ return alerts.length ? ( {alerts.map(i => ( - + {i.tone === 'error' ? '✗' : '!'} {i.text} ))} @@ -773,19 +773,19 @@ export const ToolTrail = memo(function ToolTrail({ } }} > - - {detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '} + + {detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '} {thinkingLive ? ( - + Thinking ) : ( - + Thinking )} {thinkingTokensLabel ? ( - + {' '} {thinkingTokensLabel} @@ -843,7 +843,7 @@ export const ToolTrail = memo(function ToolTrail({ color={group.color} content={ <> - + {group.content} } @@ -952,7 +952,7 @@ export const ToolTrail = memo(function ToolTrail({ color={t.color.statusFg} content={ <> - Σ + Σ {totalTokensLabel} } diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts new file mode 100644 index 0000000000..91da2121d3 --- /dev/null +++ b/ui-tui/src/config/env.ts @@ -0,0 +1,5 @@ +export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() + +export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test( + (process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase() +) diff --git a/ui-tui/src/config/limits.ts b/ui-tui/src/config/limits.ts new file mode 100644 index 0000000000..aa1090396b --- /dev/null +++ b/ui-tui/src/config/limits.ts @@ -0,0 +1,5 @@ +export const LARGE_PASTE = { chars: 8000, lines: 80 } +export const LONG_MSG = 300 +export const MAX_HISTORY = 800 +export const THINKING_COT_MAX = 160 +export const WHEEL_SCROLL_STEP = 3 diff --git a/ui-tui/src/config/timing.ts b/ui-tui/src/config/timing.ts new file mode 100644 index 0000000000..63498dbae8 --- /dev/null +++ b/ui-tui/src/config/timing.ts @@ -0,0 +1,2 @@ +export const STREAM_BATCH_MS = 16 +export const REASONING_PULSE_MS = 700 diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts deleted file mode 100644 index 9e8cb5a2ba..0000000000 --- a/ui-tui/src/constants.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { Theme } from './theme.js' -import type { Role, Usage } from './types.js' - -export const FACES = [ - '(。•́︿•̀。)', - '(◔_◔)', - '(¬‿¬)', - '( •_•)>⌐■-■', - '(⌐■_■)', - '(´・_・`)', - '◉_◉', - '(°ロ°)', - '( ˘⌣˘)♡', - 'ヽ(>∀<☆)☆', - '٩(๑❛ᴗ❛๑)۶', - '(⊙_⊙)', - '(¬_¬)', - '( ͡° ͜ʖ ͡°)', - 'ಠ_ಠ' -] - -export const HOTKEYS: [string, string][] = [ - ['Ctrl+C', 'interrupt / clear draft / exit'], - ['Ctrl+D', 'exit'], - ['Ctrl+G', 'open $EDITOR for prompt'], - ['Ctrl+L', 'new session (clear)'], - ['Alt+V / /paste', 'paste clipboard image'], - ['Tab', 'apply completion'], - ['↑/↓', 'completions / queue edit / history'], - ['Ctrl+A/E', 'home / end of line'], - ['Ctrl+Z / Ctrl+Y', 'undo / redo input edits'], - ['Ctrl+W', 'delete word'], - ['Ctrl+U/K', 'delete to start / end'], - ['Ctrl+←/→', 'jump word'], - ['Home/End', 'start / end of line'], - ['Shift+Enter / Alt+Enter', 'insert newline'], - ['\\+Enter', 'multi-line continuation (fallback)'], - ['!cmd', 'run shell command'], - ['{!cmd}', 'interpolate shell output inline'] -] - -export const INTERPOLATION_RE = /\{!(.+?)\}/g -export const LONG_MSG = 300 - -export const PLACEHOLDERS = [ - 'Ask me anything…', - 'Try "explain this codebase"', - 'Try "write a test for…"', - 'Try "refactor the auth module"', - 'Try "/help" for commands', - 'Try "fix the lint errors"', - 'Try "how does the config loader work?"' -] - -export const ROLE: Record { body: string; glyph: string; prefix: string }> = { - assistant: t => ({ body: t.color.cornsilk, glyph: t.brand.tool, prefix: t.color.bronze }), - system: t => ({ body: '', glyph: '·', prefix: t.color.dim }), - tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }), - user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label }) -} - -export const TOOL_VERBS: Record = { - browser: 'browsing', - clarify: 'asking', - create_file: 'creating', - delegate_task: 'delegating', - delete_file: 'deleting', - execute_code: 'executing', - image_generate: 'generating', - list_files: 'listing', - memory: 'remembering', - patch: 'patching', - read_file: 'reading', - run_command: 'running', - search_code: 'searching', - search_files: 'searching', - terminal: 'terminal', - web_extract: 'extracting', - web_search: 'searching', - write_file: 'writing' -} - -export const VERBS = [ - 'pondering', - 'contemplating', - 'musing', - 'cogitating', - 'ruminating', - 'deliberating', - 'mulling', - 'reflecting', - 'processing', - 'reasoning', - 'analyzing', - 'computing', - 'synthesizing', - 'formulating', - 'brainstorming' -] - -export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 } diff --git a/ui-tui/src/content/charms.ts b/ui-tui/src/content/charms.ts new file mode 100644 index 0000000000..546e44dd09 --- /dev/null +++ b/ui-tui/src/content/charms.ts @@ -0,0 +1 @@ +export const LONG_RUN_CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…'] diff --git a/ui-tui/src/content/faces.ts b/ui-tui/src/content/faces.ts new file mode 100644 index 0000000000..1bb64debb2 --- /dev/null +++ b/ui-tui/src/content/faces.ts @@ -0,0 +1,17 @@ +export const FACES = [ + '(。•́︿•̀。)', + '(◔_◔)', + '(¬‿¬)', + '( •_•)>⌐■-■', + '(⌐■_■)', + '(´・_・`)', + '◉_◉', + '(°ロ°)', + '( ˘⌣˘)♡', + 'ヽ(>∀<☆)☆', + '٩(๑❛ᴗ❛๑)۶', + '(⊙_⊙)', + '(¬_¬)', + '( ͡° ͜ʖ ͡°)', + 'ಠ_ಠ' +] diff --git a/ui-tui/src/content/fortunes.ts b/ui-tui/src/content/fortunes.ts new file mode 100644 index 0000000000..cd88dc4786 --- /dev/null +++ b/ui-tui/src/content/fortunes.ts @@ -0,0 +1,40 @@ +const FORTUNES = [ + 'you are one clean refactor away from clarity', + 'a tiny rename today prevents a huge bug tomorrow', + 'your next commit message will be immaculate', + 'the edge case you are ignoring is already solved in your head', + 'minimal diff, maximal calm', + 'today favors bold deletions over new abstractions', + 'the right helper is already in your codebase', + 'you will ship before overthinking catches up', + 'tests are about to save your future self', + 'your instincts are correctly suspicious of that one branch' +] + +const LEGENDARY_FORTUNES = [ + 'legendary drop: one-line fix, first try', + 'legendary drop: every flaky test passes cleanly', + 'legendary drop: your diff teaches by itself' +] + +const hash = (input: string) => { + let out = 2166136261 + + for (let i = 0; i < input.length; i++) { + out ^= input.charCodeAt(i) + out = Math.imul(out, 16777619) + } + + return out >>> 0 +} + +const fromScore = (score: number) => { + const rare = score % 20 === 0 + const bag = rare ? LEGENDARY_FORTUNES : FORTUNES + + return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}` +} + +export const randomFortune = () => fromScore(Math.floor(Math.random() * 0x7fffffff)) + +export const dailyFortune = (seed: null | string) => fromScore(hash(`${seed || 'anon'}|${new Date().toDateString()}`)) diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts new file mode 100644 index 0000000000..f08ca61365 --- /dev/null +++ b/ui-tui/src/content/hotkeys.ts @@ -0,0 +1,19 @@ +export const HOTKEYS: [string, string][] = [ + ['Ctrl+C', 'interrupt / clear draft / exit'], + ['Ctrl+D', 'exit'], + ['Ctrl+G', 'open $EDITOR for prompt'], + ['Ctrl+L', 'new session (clear)'], + ['Alt+V / /paste', 'paste clipboard image'], + ['Tab', 'apply completion'], + ['↑/↓', 'completions / queue edit / history'], + ['Ctrl+A/E', 'home / end of line'], + ['Ctrl+Z / Ctrl+Y', 'undo / redo input edits'], + ['Ctrl+W', 'delete word'], + ['Ctrl+U/K', 'delete to start / end'], + ['Ctrl+←/→', 'jump word'], + ['Home/End', 'start / end of line'], + ['Shift+Enter / Alt+Enter', 'insert newline'], + ['\\+Enter', 'multi-line continuation (fallback)'], + ['!cmd', 'run shell command'], + ['{!cmd}', 'interpolate shell output inline'] +] diff --git a/ui-tui/src/content/placeholders.ts b/ui-tui/src/content/placeholders.ts new file mode 100644 index 0000000000..3d97eecac0 --- /dev/null +++ b/ui-tui/src/content/placeholders.ts @@ -0,0 +1,13 @@ +import { pick } from '../lib/text.js' + +export const PLACEHOLDERS = [ + 'Ask me anything…', + 'Try "explain this codebase"', + 'Try "write a test for…"', + 'Try "refactor the auth module"', + 'Try "/help" for commands', + 'Try "fix the lint errors"', + 'Try "how does the config loader work?"' +] + +export const PLACEHOLDER = pick(PLACEHOLDERS) diff --git a/ui-tui/src/content/verbs.ts b/ui-tui/src/content/verbs.ts new file mode 100644 index 0000000000..41b441d5cd --- /dev/null +++ b/ui-tui/src/content/verbs.ts @@ -0,0 +1,38 @@ +export const TOOL_VERBS: Record = { + browser: 'browsing', + clarify: 'asking', + create_file: 'creating', + delegate_task: 'delegating', + delete_file: 'deleting', + execute_code: 'executing', + image_generate: 'generating', + list_files: 'listing', + memory: 'remembering', + patch: 'patching', + read_file: 'reading', + run_command: 'running', + search_code: 'searching', + search_files: 'searching', + terminal: 'terminal', + web_extract: 'extracting', + web_search: 'searching', + write_file: 'writing' +} + +export const VERBS = [ + 'pondering', + 'contemplating', + 'musing', + 'cogitating', + 'ruminating', + 'deliberating', + 'mulling', + 'reflecting', + 'processing', + 'reasoning', + 'analyzing', + 'computing', + 'synthesizing', + 'formulating', + 'brainstorming' +] diff --git a/ui-tui/src/domain/details.ts b/ui-tui/src/domain/details.ts new file mode 100644 index 0000000000..84c2cd80e7 --- /dev/null +++ b/ui-tui/src/domain/details.ts @@ -0,0 +1,29 @@ +import type { DetailsMode } from '../types.js' + +const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] + +const THINKING_FALLBACK: Record = { + collapsed: 'collapsed', + full: 'expanded', + truncated: 'collapsed' +} + +export const parseDetailsMode = (v: unknown): DetailsMode | null => { + const s = typeof v === 'string' ? v.trim().toLowerCase() : '' + + return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null +} + +export const resolveDetailsMode = ( + d: { details_mode?: unknown; thinking_mode?: unknown } | null | undefined +): DetailsMode => + parseDetailsMode(d?.details_mode) ?? + THINKING_FALLBACK[ + String(d?.thinking_mode ?? '') + .trim() + .toLowerCase() + ] ?? + 'collapsed' + +export const nextDetailsMode = (m: DetailsMode): DetailsMode => + DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]! diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts new file mode 100644 index 0000000000..2b7f4a513e --- /dev/null +++ b/ui-tui/src/domain/messages.ts @@ -0,0 +1,102 @@ +import { LONG_MSG } from '../config/limits.js' +import { buildToolTrailLine, fmtK } from '../lib/text.js' +import type { Msg, SessionInfo } from '../types.js' + +interface ImageMeta { + height?: number + token_estimate?: number + width?: number +} + +interface TranscriptRow { + context?: string + name?: string + role?: string + text?: string +} + +export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' }) + +export const imageTokenMeta = (info: ImageMeta | null | undefined) => + [ + info?.width && info.height ? `${info.width}x${info.height}` : '', + typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : '' + ] + .filter(Boolean) + .join(' · ') + +export const userDisplay = (text: string): string => { + if (text.length <= LONG_MSG) { + return text + } + + const first = text.split('\n')[0]?.trim() ?? '' + const words = first.split(/\s+/).filter(Boolean) + const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80) + + return `${prefix || '(message)'} [long message]` +} + +export const toTranscriptMessages = (rows: unknown): Msg[] => { + if (!Array.isArray(rows)) { + return [] + } + + const result: Msg[] = [] + let pendingTools: string[] = [] + + for (const row of rows) { + if (!row || typeof row !== 'object') { + continue + } + + const { context, name, role, text } = row as TranscriptRow + + if (role === 'tool') { + pendingTools.push(buildToolTrailLine(name ?? 'tool', context ?? '')) + + continue + } + + if (typeof text !== 'string' || !text.trim()) { + continue + } + + if (role === 'assistant') { + const msg: Msg = { role, text } + + if (pendingTools.length) { + msg.tools = pendingTools + pendingTools = [] + } + + result.push(msg) + + continue + } + + if (role === 'user' || role === 'system') { + pendingTools = [] + result.push({ role, text }) + } + } + + return result +} + +export function fmtDuration(ms: number) { + const total = Math.max(0, Math.floor(ms / 1000)) + const hours = Math.floor(total / 3600) + const mins = Math.floor((total % 3600) / 60) + const secs = total % 60 + + if (hours > 0) { + return `${hours}h ${mins}m` + } + + if (mins > 0) { + return `${mins}m ${secs}s` + } + + return `${secs}s` +} diff --git a/ui-tui/src/domain/paths.ts b/ui-tui/src/domain/paths.ts new file mode 100644 index 0000000000..120a71d79b --- /dev/null +++ b/ui-tui/src/domain/paths.ts @@ -0,0 +1,5 @@ +export const shortCwd = (cwd: string, max = 28) => { + const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd + + return p.length <= max ? p : `…${p.slice(-(max - 1))}` +} diff --git a/ui-tui/src/domain/roles.ts b/ui-tui/src/domain/roles.ts new file mode 100644 index 0000000000..f92d175e65 --- /dev/null +++ b/ui-tui/src/domain/roles.ts @@ -0,0 +1,9 @@ +import type { Theme } from '../theme.js' +import type { Role } from '../types.js' + +export const ROLE: Record { body: string; glyph: string; prefix: string }> = { + assistant: t => ({ body: t.color.cornsilk, glyph: t.brand.tool, prefix: t.color.bronze }), + system: t => ({ body: '', glyph: '·', prefix: t.color.dim }), + tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }), + user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label }) +} diff --git a/ui-tui/src/domain/slash.ts b/ui-tui/src/domain/slash.ts new file mode 100644 index 0000000000..fd5b327d78 --- /dev/null +++ b/ui-tui/src/domain/slash.ts @@ -0,0 +1,25 @@ +export interface ParsedSlashCommand { + arg: string + cmd: string + name: string +} + +export const looksLikeSlashCommand = (text: string) => { + if (!text.startsWith('/')) { + return false + } + + const first = text.split(/\s+/, 1)[0] || '' + + return !first.slice(1).includes('/') +} + +export const parseSlashCommand = (cmd: string): ParsedSlashCommand => { + const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/) + + return { + arg: rest.join(' '), + cmd, + name: rawName.toLowerCase() + } +} diff --git a/ui-tui/src/domain/usage.ts b/ui-tui/src/domain/usage.ts new file mode 100644 index 0000000000..508195f253 --- /dev/null +++ b/ui-tui/src/domain/usage.ts @@ -0,0 +1,3 @@ +import type { Usage } from '../types.js' + +export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 } diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts new file mode 100644 index 0000000000..783bc52258 --- /dev/null +++ b/ui-tui/src/domain/viewport.ts @@ -0,0 +1,44 @@ +import type { Msg } from '../types.js' + +import { userDisplay } from './messages.js' + +const upperBound = (offsets: ArrayLike, target: number) => { + let lo = 0 + let hi = offsets.length + + while (lo < hi) { + const mid = (lo + hi) >> 1 + + offsets[mid]! <= target ? (lo = mid + 1) : (hi = mid) + } + + return lo +} + +export const stickyPromptFromViewport = ( + messages: readonly Msg[], + offsets: ArrayLike, + top: number, + sticky: boolean +) => { + if (sticky || !messages.length) { + return '' + } + + const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) + const aboveViewport = (i: number) => (offsets[i] ?? 0) + 1 < top + + if (messages[first]?.role === 'user' && !aboveViewport(first)) { + return '' + } + + for (let i = first - 1; i >= 0; i--) { + if (messages[i]?.role !== 'user' || !aboveViewport(i)) { + continue + } + + return userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() + } + + return '' +} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 7fab065971..ee0e431230 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -20,6 +20,8 @@ export interface GatewayTranscriptMessage { text?: string } +// ── Commands / completion ──────────────────────────────────────────── + export interface CommandsCatalogResponse { canon?: Record categories?: SlashCategory[] @@ -34,6 +36,18 @@ export interface CompletionResponse { replace_from?: number } +export interface SlashExecResponse { + output?: string + warning?: string +} + +export type CommandDispatchResponse = + | { output?: string; type: 'exec' | 'plugin' } + | { target: string; type: 'alias' } + | { message?: string; name: string; type: 'skill' } + +// ── Config ─────────────────────────────────────────────────────────── + export interface ConfigDisplayConfig { bell_on_complete?: boolean details_mode?: string @@ -43,19 +57,29 @@ export interface ConfigDisplayConfig { } export interface ConfigFullResponse { - config?: { - display?: ConfigDisplayConfig - } + config?: { display?: ConfigDisplayConfig } } export interface ConfigMtimeResponse { mtime?: number } -export interface BackgroundStartResponse { - task_id?: string +export interface ConfigGetValueResponse { + display?: string + home?: string + value?: string } +export interface ConfigSetResponse { + credential_warning?: string + history_reset?: boolean + info?: SessionInfo + value?: string + warning?: string +} + +// ── Session lifecycle ──────────────────────────────────────────────── + export interface SessionCreateResponse { info?: SessionInfo & { credential_warning?: string } session_id: string @@ -91,21 +115,133 @@ export interface SessionHistoryResponse { messages?: GatewayTranscriptMessage[] } -export interface ModelOptionProvider { - is_current?: boolean - models?: string[] - name: string - slug: string - total_models?: number - warning?: string +export interface SessionCompressResponse { + info?: SessionInfo + messages?: GatewayTranscriptMessage[] + removed?: number + usage?: Usage } -export interface ModelOptionsResponse { - model?: string - provider?: string - providers?: ModelOptionProvider[] +export interface SessionBranchResponse { + session_id?: string + title?: string } +export interface SessionTitleResponse { + title?: string +} + +export interface SessionSaveResponse { + file?: string +} + +export interface SessionUsageResponse { + cache_read?: number + cache_write?: number + calls?: number + compressions?: number + context_max?: number + context_percent?: number + context_used?: number + cost_status?: 'estimated' | 'exact' + cost_usd?: number + input?: number + model?: string + output?: number + total?: number +} + +export interface SessionCloseResponse { + ok?: boolean +} + +export interface SessionInterruptResponse { + ok?: boolean +} + +// ── Prompt / submission ────────────────────────────────────────────── + +export interface PromptSubmitResponse { + ok?: boolean +} + +export interface BackgroundStartResponse { + task_id?: string +} + +export interface BtwStartResponse { + ok?: boolean +} + +export interface ClarifyRespondResponse { + ok?: boolean +} + +export interface ApprovalRespondResponse { + ok?: boolean +} + +export interface SudoRespondResponse { + ok?: boolean +} + +export interface SecretRespondResponse { + ok?: boolean +} + +// ── Shell / clipboard / input ──────────────────────────────────────── + +export interface ShellExecResponse { + code: number + stderr?: string + stdout?: string +} + +export interface ClipboardPasteResponse { + attached?: boolean + count?: number + height?: number + message?: string + token_estimate?: number + width?: number +} + +export interface InputDetectDropResponse { + height?: number + is_image?: boolean + matched?: boolean + name?: string + text?: string + token_estimate?: number + width?: number +} + +export interface TerminalResizeResponse { + ok?: boolean +} + +// ── Image attach ───────────────────────────────────────────────────── + +export interface ImageAttachResponse { + height?: number + name?: string + remainder?: string + token_estimate?: number + width?: number +} + +// ── Voice ──────────────────────────────────────────────────────────── + +export interface VoiceToggleResponse { + enabled?: boolean +} + +export interface VoiceRecordResponse { + text?: string +} + +// ── Tools / toolsets ───────────────────────────────────────────────── + export interface ToolsetDetails { description: string enabled: boolean @@ -142,15 +278,121 @@ export interface ToolsConfigureResponse { unknown?: string[] } -export interface SlashExecResponse { - output?: string +export interface ToolsetsListResponse { + toolsets?: { + description: string + enabled: boolean + name: string + tool_count: number + }[] +} + +// ── Ops: rollback / browser / plugins / skills / agents / cron ─────── + +export interface RollbackCheckpoint { + hash?: string + message?: string +} + +export interface RollbackListResponse { + checkpoints?: RollbackCheckpoint[] +} + +export interface RollbackActionResponse { + diff?: string + message?: string + rendered?: string +} + +export interface BrowserManageResponse { + connected?: boolean + url?: string +} + +export interface PluginInfo { + enabled?: boolean + name?: string + version?: string +} + +export interface PluginsListResponse { + plugins?: PluginInfo[] +} + +export interface SkillsListResponse { + skills?: Record +} + +export interface SkillsBrowseItem { + description?: string + name?: string +} + +export interface SkillsBrowseResponse { + items?: SkillsBrowseItem[] + page?: number + total?: number + total_pages?: number +} + +export interface AgentProcess { + command?: string + session_id: string + status?: 'finished' | 'running' +} + +export interface AgentsListResponse { + processes?: AgentProcess[] +} + +export interface CronJob { + job_id?: string + name?: string + schedule?: string + state?: string +} + +export interface CronListResponse { + jobs?: CronJob[] +} + +export interface ConfigShowSection { + rows?: [string, string][] + title?: string +} + +export interface ConfigShowResponse { + sections?: ConfigShowSection[] +} + +// ── Insights / MCP ─────────────────────────────────────────────────── + +export interface InsightsResponse { + days?: number + messages?: number + sessions?: number +} + +export interface ReloadMcpResponse { + ok?: boolean +} + +export interface ModelOptionProvider { + is_current?: boolean + models?: string[] + name: string + slug: string + total_models?: number warning?: string } -export type CommandDispatchResponse = - | { output?: string; type: 'exec' | 'plugin' } - | { target: string; type: 'alias' } - | { message?: string; name: string; type: 'skill' } +export interface ModelOptionsResponse { + model?: string + provider?: string + providers?: ModelOptionProvider[] +} + +// ── Subagent events ────────────────────────────────────────────────── export interface SubagentEventPayload { duration_seconds?: number diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 9d6a9a58e0..c6b991a5ee 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,4 +1,4 @@ -import { INTERPOLATION_RE, LONG_MSG } from '../constants.js' +import { THINKING_COT_MAX } from '../config/limits.js' import type { ThinkingMode } from '../types.js' // eslint-disable-next-line no-control-regex @@ -73,7 +73,7 @@ export const pasteTokenLabel = (text: string, lineCount: number) => { : `[[ ${preview} [${fmtK(lineCount)} lines] ]]` } -export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => { +export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => { const raw = reasoning.trim() if (!raw || mode === 'collapsed') { @@ -155,8 +155,6 @@ export const lastCotTrailIndex = (trail: readonly string[]) => { return -1 } -export const THINKING_COT_MAX = 160 - export const estimateRows = (text: string, w: number, compact = false) => { let fence: { char: '`' | '~'; len: number } | null = null let rows = 0 @@ -213,25 +211,7 @@ const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase()) -export const hasInterpolation = (s: string) => { - INTERPOLATION_RE.lastIndex = 0 - - return INTERPOLATION_RE.test(s) -} - export const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! -export const userDisplay = (text: string): string => { - if (text.length <= LONG_MSG) { - return text - } - - const first = text.split('\n')[0]?.trim() ?? '' - const words = first.split(/\s+/).filter(Boolean) - const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80) - - return `${prefix || '(message)'} [long message]` -} - export const isPasteBackedText = (text: string): boolean => /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text) diff --git a/ui-tui/src/protocol/interpolation.ts b/ui-tui/src/protocol/interpolation.ts new file mode 100644 index 0000000000..b83d16c5c2 --- /dev/null +++ b/ui-tui/src/protocol/interpolation.ts @@ -0,0 +1,7 @@ +export const INTERPOLATION_RE = /\{!(.+?)\}/g + +export const hasInterpolation = (s: string) => { + INTERPOLATION_RE.lastIndex = 0 + + return INTERPOLATION_RE.test(s) +} diff --git a/ui-tui/src/protocol/paste.ts b/ui-tui/src/protocol/paste.ts new file mode 100644 index 0000000000..9eae137cea --- /dev/null +++ b/ui-tui/src/protocol/paste.ts @@ -0,0 +1 @@ +export const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g