refactor(tui): store-driven turn state + slash registry + module split

Hoist turn state from a 286-line hook into $turnState atom + turnController
singleton. createGatewayEventHandler becomes a typed dispatch over the
controller; its ctx shrinks from 30 fields to 5. Event-handler refs and 16
threaded actions are gone.

Fold three createSlash*Handler factories into a data-driven SlashCommand[]
registry under slash/commands/{core,session,ops}.ts. Aliases are data;
findSlashCommand does name+alias lookup. Shared guarded/guardedErr combinator
in slash/guarded.ts.

Split constants.ts + app/helpers.ts into config/ (timing/limits/env),
content/ (faces/placeholders/hotkeys/verbs/charms/fortunes), domain/ (roles/
details/messages/paths/slash/viewport/usage), protocol/ (interpolation/paste).

Type every RPC response in gatewayTypes.ts (26 new interfaces); drop all
`(r: any)` across slash + main app.

Shrink useMainApp from 1216 -> 646 lines by extracting useSessionLifecycle,
useSubmission, useConfigSync. Add <Fg> themed primitive and strip ~50
`as any` color casts.

Tests: 50 passing. Build + type-check clean.
This commit is contained in:
Brooklyn Nicholson 2026-04-16 12:18:56 -05:00
parent 9c71f3a6ea
commit 68ecdb6e26
56 changed files with 3666 additions and 4117 deletions

View file

@ -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', () => {

View file

@ -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 = <T>(current: T) => ({ current })
const buildCtx = (appended: Msg[]) =>
({
composer: {
dequeue: () => undefined,
queueEditRef: ref<null | number>(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<string>()),
protocolWarnedRef: ref(false),
reasoningRef: ref('mapped the page'),
statusTimerRef: ref<ReturnType<typeof setTimeout> | null>(null),
toolTokenAccRef: ref(0),
toolCompleteRibbonRef: ref(null),
turnToolsRef: ref([] as string[])
}
const onEvent = createGatewayEventHandler({
composer: {
dequeue: () => undefined,
queueEditRef: ref<number | null>(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<string>()),
protocolWarnedRef: ref(false),
reasoningRef: ref('mapped the page'),
statusTimerRef: ref<ReturnType<typeof setTimeout> | null>(null),
toolTokenAccRef: ref(0),
toolCompleteRibbonRef: ref(null),
turnToolsRef: ref([] as string[])
}
const buildHandler = () =>
createGatewayEventHandler({
composer: {
dequeue: () => undefined,
queueEditRef: ref<number | null>(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<string>()),
protocolWarnedRef: ref(false),
reasoningRef: ref(''),
statusTimerRef: ref<ReturnType<typeof setTimeout> | 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<number | null>(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<string>()),
protocolWarnedRef: ref(false),
reasoningRef: ref(''),
statusTimerRef: ref<ReturnType<typeof setTimeout> | 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<number | null>(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<string>()),
protocolWarnedRef: ref(false),
reasoningRef: ref(''),
statusTimerRef: ref<ReturnType<typeof setTimeout> | null>(null),
toolTokenAccRef: ref(0),
toolCompleteRibbonRef: ref(null),
turnToolsRef: ref([] as string[])
}
const onEvent = createGatewayEventHandler({
composer: {
dequeue: () => undefined,
queueEditRef: ref<number | null>(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)
})
})

View file

@ -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 }) {

View file

@ -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

View file

@ -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) =>
<T>(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<typeof setTimeout> | null = null
let toolProgressTimer: ReturnType<typeof setTimeout> | null = null
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = 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<CommandsCatalogResponse>('commands.catalog', {})
.then(r => {
if (!r?.pairs) {
return
}
setCatalog({
canon: (r.canon ?? {}) as Record<string, string>,
categories: r.categories ?? [],
pairs: r.pairs as [string, string][],
skillCount: (r.skill_count ?? 0) as number,
sub: (r.sub ?? {}) as Record<string, string[]>
})
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<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
.then(raw => {
const r = asRpcResult<SessionResumeResponse>(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<CommandsCatalogResponse>('commands.catalog', {})
.then(r => {
if (!r?.pairs) {
return
}
setCatalog({
canon: (r.canon ?? {}) as Record<string, string>,
categories: r.categories ?? [],
pairs: r.pairs as [string, string][],
skillCount: (r.skill_count ?? 0) as number,
sub: (r.sub ?? {}) as Record<string, string[]>
})
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<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
.then(raw => {
const r = asRpcResult<SessionResumeResponse>(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
}
}
}
}

View file

@ -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 =
<T>(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<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: sidAtSend })
gw.request<SlashExecResponse>('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

View file

@ -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<number>,
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 ''
}

View file

@ -36,7 +36,7 @@ export interface CompletionItem {
}
export interface GatewayRpc {
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<T | null>
<T extends RpcResult = RpcResult>(method: string, params?: Record<string, unknown>): Promise<null | T>
}
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<number>
setHistoryIdx: StateSetter<number | null>
setHistoryIdx: StateSetter<null | number>
setInput: StateSetter<string>
setInputBuf: StateSetter<string[]>
setPasteSnips: StateSetter<PasteSnippet[]>
setQueueEdit: (index: number | null) => void
setQueueEdit: (index: null | number) => void
syncQueue: () => void
}
export interface ComposerRefs {
historyDraftRef: MutableRefObject<string>
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<number | null>
queueEditRef: MutableRefObject<null | number>
queueRef: MutableRefObject<string[]>
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<string, unknown>) => Promise<unknown> }
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<ActivityItem[]>
setReasoning: StateSetter<string>
setReasoningTokens: StateSetter<number>
setReasoningActive: StateSetter<boolean>
setToolTokens: StateSetter<number>
setReasoningStreaming: StateSetter<boolean>
setStreaming: StateSetter<string>
setSubagents: StateSetter<SubagentProgress[]>
setTools: StateSetter<ActiveTool[]>
setTurnTrail: StateSetter<string[]>
}
export interface TurnRefs {
activeToolsRef: MutableRefObject<ActiveTool[]>
bufRef: MutableRefObject<string>
interruptedRef: MutableRefObject<boolean>
lastStatusNoteRef: MutableRefObject<string>
persistedToolLabelsRef: MutableRefObject<Set<string>>
protocolWarnedRef: MutableRefObject<boolean>
reasoningRef: MutableRefObject<string>
reasoningStreamingTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
reasoningTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
statusTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
streamTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
toolTokenAccRef: MutableRefObject<number>
toolCompleteRibbonRef: MutableRefObject<ToolCompleteRibbon | null>
turnToolsRef: MutableRefObject<string[]>
}
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<ScrollBoxHandle | null>
scrollRef: RefObject<null | ScrollBoxHandle>
scrollWithSelection: (delta: number) => void
selection: SelectionApi
stdout?: NodeJS.WriteStream
}
turn: {
actions: TurnActions
refs: TurnRefs
}
voice: {
recording: boolean
setProcessing: StateSetter<boolean>
@ -262,7 +187,7 @@ export interface InputHandlerResult {
export interface GatewayEventHandlerContext {
composer: {
dequeue: () => string | undefined
queueEditRef: MutableRefObject<number | null>
queueEditRef: MutableRefObject<null | number>
sendQueued: (text: string) => void
}
gateway: GatewayServices
@ -271,7 +196,7 @@ export interface GatewayEventHandlerContext {
colsRef: MutableRefObject<number>
newSession: (msg?: string) => void
resetSession: () => void
setCatalog: StateSetter<SlashCatalog | null>
setCatalog: StateSetter<null | SlashCatalog>
}
system: {
bellOnComplete: boolean
@ -282,45 +207,9 @@ export interface GatewayEventHandlerContext {
appendMessage: (msg: Msg) => void
setHistoryItems: StateSetter<Msg[]>
}
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<number>
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<unknown>
closeSession: (targetSid?: null | string) => Promise<unknown>
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<number>
}
slashFlightRef: MutableRefObject<number>
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<string>
@ -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<ScrollBoxHandle | null>
scrollRef: RefObject<null | ScrollBoxHandle>
virtualHistory: VirtualHistoryState
virtualRows: TranscriptRow[]
}

View file

@ -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<ConfigSetResponse>('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<ConfigGetValueResponse>('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<ConfigSetResponse>('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<ConfigSetResponse>('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<SessionUndoResponse>('session.undo', { session_id: ctx.sid }).then(
ctx.guarded<SessionUndoResponse>(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<SessionUndoResponse>('session.undo', { session_id: ctx.sid }).then(
ctx.guarded<SessionUndoResponse>(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)
})
)
}
}
]

View file

@ -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<SlashExecResponse>('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<RollbackListResponse>('rollback.list', { session_id: ctx.sid }).then(
ctx.guarded<RollbackListResponse>(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<RollbackActionResponse>(method, {
hash,
session_id: ctx.sid,
...(sub === 'diff' || !filePath ? {} : { file_path: filePath })
})
.then(ctx.guarded<RollbackActionResponse>(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<BrowserManageResponse>('browser.manage', { action, ...(url ? { url } : {}) })
.then(
ctx.guarded<BrowserManageResponse>(r =>
ctx.transcript.sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected')
)
)
}
},
{
help: 'list installed plugins',
name: 'plugins',
run: (_arg, ctx) => {
ctx.gateway.rpc<PluginsListResponse>('plugins.list', {}).then(
ctx.guarded<PluginsListResponse>(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<SkillsListResponse>('skills.manage', { action: 'list' }).then(
ctx.guarded<SkillsListResponse>(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<SkillsBrowseResponse>('skills.manage', { action: 'browse', page: pageNumber }).then(
ctx.guarded<SkillsBrowseResponse>(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<AgentsListResponse>('agents.list', {})
.then(
ctx.guarded<AgentsListResponse>(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<CronListResponse>('cron.manage', { action: 'list' })
.then(
ctx.guarded<CronListResponse>(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<ConfigShowResponse>('config.show', {})
.then(
ctx.guarded<ConfigShowResponse>(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<ToolsShowResponse>('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<ToolsListResponse>('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> [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<ToolsConfigureResponse>('tools.configure', { action: subcommand, names, session_id: ctx.sid })
.then(
ctx.guarded<ToolsConfigureResponse>(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<ToolsetsListResponse>('toolsets.list', { session_id: ctx.sid })
.then(
ctx.guarded<ToolsetsListResponse>(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)
}
}
]

View file

@ -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<string, string> = {
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 <prompt>')
}
ctx.gateway.rpc<BackgroundStartResponse>('prompt.background', { session_id: ctx.sid, text: arg }).then(
ctx.guarded<BackgroundStartResponse>(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 <question>')
}
ctx.gateway.rpc<BtwStartResponse>('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<ConfigSetResponse>('config.set', { key: 'model', session_id: ctx.sid, value: arg.trim() }).then(
ctx.guarded<ConfigSetResponse>(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<ImageAttachResponse>('image.attach', { path: arg, session_id: ctx.sid }).then(
ctx.guarded<ImageAttachResponse>(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<SlashExecResponse>('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<ConfigSetResponse>('config.set', { key: 'skin', value: arg })
.then(ctx.guarded<ConfigSetResponse>(r => r.value && ctx.transcript.sys(`skin → ${r.value}`)))
}
ctx.gateway
.rpc<ConfigGetValueResponse>('config.get', { key: 'skin' })
.then(ctx.guarded<ConfigGetValueResponse>(r => ctx.transcript.sys(`skin: ${r.value || 'default'}`)))
}
},
{
help: 'toggle yolo mode',
name: 'yolo',
run: (_arg, ctx) => {
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'yolo', session_id: ctx.sid })
.then(ctx.guarded<ConfigSetResponse>(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<ConfigGetValueResponse>('config.get', { key: 'reasoning' })
.then(
ctx.guarded<ConfigGetValueResponse>(
r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`)
)
)
}
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg })
.then(ctx.guarded<ConfigSetResponse>(r => r.value && ctx.transcript.sys(`reasoning: ${r.value}`)))
}
},
{
help: 'cycle verbose output',
name: 'verbose',
run: (arg, ctx) => {
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'verbose', session_id: ctx.sid, value: arg || 'cycle' })
.then(ctx.guarded<ConfigSetResponse>(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<ConfigSetResponse>('config.set', { key: 'personality', session_id: ctx.sid, value: arg })
.then(
ctx.guarded<ConfigSetResponse>(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<SlashExecResponse>('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<SessionCompressResponse>('session.compress', {
session_id: ctx.sid,
...(arg ? { focus_topic: arg } : {})
})
.then(
ctx.guarded<SessionCompressResponse>(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<SessionBranchResponse>('session.branch', { name: arg, session_id: ctx.sid }).then(
ctx.guarded<SessionBranchResponse>(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<ReloadMcpResponse>('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<SessionTitleResponse>('session.title', { session_id: ctx.sid, ...(arg ? { title: arg } : {}) })
.then(ctx.guarded<SessionTitleResponse>(r => ctx.transcript.sys(`title: ${r.title || '(none)'}`)))
}
},
{
help: 'session usage',
name: 'usage',
run: (_arg, ctx) => {
ctx.gateway.rpc<SessionUsageResponse>('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<SessionSaveResponse>('session.save', { session_id: ctx.sid })
.then(ctx.guarded<SessionSaveResponse>(r => r.file && ctx.transcript.sys(`saved: ${r.file}`)))
}
},
{
help: 'view message history',
name: 'history',
run: (_arg, ctx) => {
ctx.gateway.rpc<SessionHistoryResponse>('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<ConfigGetValueResponse>('config.get', { key: 'profile' }).then(
ctx.guarded<ConfigGetValueResponse>(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<VoiceToggleResponse>('voice.toggle', { action }).then(
ctx.guarded<VoiceToggleResponse>(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<InsightsResponse>('insights.get', { days: parseInt(arg) || 30 }).then(
ctx.guarded<InsightsResponse>(r =>
ctx.transcript.panel('Insights', [
{
rows: [
['Period', `${r.days ?? 0} days`],
['Sessions', `${r.sessions ?? 0}`],
['Messages', `${r.messages ?? 0}`]
]
}
])
)
)
}
}
]

View file

@ -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
}
}

View file

@ -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<string, string[]> | 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<ToolsShowResponse>('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<ToolsListResponse>('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> [name ...]`)
sys(`built-in toolset: /tools ${subcommand} web`)
sys(`MCP tool: /tools ${subcommand} github:create_issue`)
return true
}
rpc<ToolsConfigureResponse>('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
}

View file

@ -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<string, string> = {
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 <prompt>')
return true
}
rpc<BackgroundStartResponse>('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 <question>')
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<SessionHistoryResponse>('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
}

View file

@ -1,10 +0,0 @@
import type { SlashHandlerContext } from '../interfaces.js'
import { getUiState } from '../uiStore.js'
export function isStaleSlash(
ctx: Pick<SlashHandlerContext, 'slashFlightRef'>,
flight: number,
sid: null | string
): boolean {
return flight !== ctx.slashFlightRef.current || getUiState().sid !== sid
}

View file

@ -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<string, SlashCommand>()
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())

View file

@ -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<SlashExecResponse>('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: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
}
gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
page: (text: string, title?: string) => void
slashFlightRef: MutableRefObject<number>
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<SlashExecResponse>('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)}`)
}
})
}
})

View file

@ -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: <T>(fn: (r: T) => void) => (r: null | T) => void
guardedErr: (e: unknown) => void
shared: SlashShared
sid: null | string
slashFlightRef: MutableRefObject<number>
stale: () => boolean
ui: UiState
}
export interface SlashCommand {
aliases?: string[]
help?: string
name: string
run: (arg: string, ctx: SlashRunCtx, cmd: string) => void
usage?: string
}

View file

@ -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: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
sid: string
sys: (text: string) => void
}
type Timer = null | ReturnType<typeof setTimeout>
const clear = (t: Timer): null => {
if (t) {
clearTimeout(t)
}
return null
}
class TurnController {
bufRef = ''
interrupted = false
lastStatusNote = ''
persistedToolLabels = new Set<string>()
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<SessionInterruptResponse>('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<SubagentProgress>) {
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 }

View file

@ -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<TurnState>(buildTurnState())
export const getTurnState = () => $turnState.get()
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) => {
if (typeof next === 'function') {
$turnState.set(next($turnState.get()))
return
}
$turnState.set({ ...$turnState.get(), ...next })
}
export const resetTurnState = () => $turnState.set(buildTurnState())

View file

@ -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'

View file

@ -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'

View file

@ -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<VoiceToggleResponse>('voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled))
rpc<ConfigMtimeResponse>('config.get', { key: 'mtime' }).then(r => {
mtimeRef.current = Number(r?.mtime ?? 0)
})
rpc<ConfigFullResponse>('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<ConfigMtimeResponse>('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<ReloadMcpResponse>('reload.mcp', { session_id: sid }).then(
r => r && turnController.pushActivity('MCP reloaded after config change')
)
rpc<ConfigFullResponse>('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])
}

View file

@ -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<typeof getUiState>) => {
if (overlay.clarify) {
return actions.answerClarify('')
}
if (overlay.approval) {
return gateway
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: live.sid })
.then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied')))
}
if (overlay.sudo) {
return gateway
.rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: overlay.sudo.requestId })
.then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled')))
}
if (overlay.secret) {
return gateway
.rpc<SecretRespondResponse>('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<VoiceRecordResponse>('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<VoiceRecordResponse>('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)
}
}

View file

@ -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<string, { count: number; lastAt: number }>())
interface Slot {
count: number
lastAt: number
}
export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) {
const slots = useRef(new Map<string, Slot>())
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])
}

File diff suppressed because it is too large Load diff

View file

@ -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<Msg[]>
setLastUserMsg: StateSetter<string>
setSessionStartedAt: StateSetter<number>
setStickyPrompt: StateSetter<string>
setVoiceProcessing: StateSetter<boolean>
setVoiceRecording: StateSetter<boolean>
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<SessionCloseResponse>('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<SessionCreateResponse>('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<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id })
.then(raw => {
const r = asRpcResult<SessionResumeResponse>(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
}
}

View file

@ -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<string, string[]>()
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<PromptSubmitResponse>('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<InputDetectDropResponse>('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<ShellExecResponse>('shell.exec', { command: cmd })
.then(raw => {
const r = asRpcResult<ShellExecResponse>(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<ShellExecResponse>('shell.exec', { command: m[1]! })
.then(raw => {
const r = asRpcResult<ShellExecResponse>(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 }
}

View file

@ -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<ActivityItem[]>([])
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<SubagentProgress[]>([])
const [tools, setTools] = useState<ActiveTool[]>([])
const [turnTrail, setTurnTrail] = useState<string[]>([])
const activityIdRef = useRef(0)
const activeToolsRef = useRef<ActiveTool[]>([])
const bufRef = useRef('')
const interruptedRef = useRef(false)
const lastStatusNoteRef = useRef('')
const persistedToolLabelsRef = useRef<Set<string>>(new Set())
const protocolWarnedRef = useRef(false)
const reasoningRef = useRef('')
const reasoningStreamingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reasoningTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const toolTokenAccRef = useRef(0)
const toolCompleteRibbonRef = useRef<ToolCompleteRibbon | null>(null)
const turnToolsRef = useRef<string[]>([])
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
}
}

View file

@ -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 <Text color={color as any}>{active ? '♥' : ' '}</Text>
return <Text color={color}>{active ? '♥' : ' '}</Text>
}
export function StatusRule({
@ -108,29 +109,29 @@ export function StatusRule({
return (
<Box>
<Box flexShrink={1} width={leftWidth}>
<Text color={t.color.bronze as any} wrap="truncate-end">
<Text color={t.color.bronze} wrap="truncate-end">
{'─ '}
<Text color={statusColor as any}>{status}</Text>
<Text color={t.color.dim as any}> {model}</Text>
{ctxLabel ? <Text color={t.color.dim as any}> {ctxLabel}</Text> : null}
<Text color={statusColor}>{status}</Text>
<Text color={t.color.dim}> {model}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null}
{bar ? (
<Text color={t.color.dim as any}>
<Text color={t.color.dim}>
{' │ '}
<Text color={barColor as any}>[{bar}]</Text> <Text color={barColor as any}>{pctLabel}</Text>
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
</Text>
) : null}
{sessionStartedAt ? (
<Text color={t.color.dim as any}>
<Text color={t.color.dim}>
{' │ '}
<SessionDuration startedAt={sessionStartedAt} />
</Text>
) : null}
{voiceLabel ? <Text color={t.color.dim as any}> {voiceLabel}</Text> : null}
{bgCount > 0 ? <Text color={t.color.dim as any}> {bgCount} bg</Text> : null}
{voiceLabel ? <Text color={t.color.dim}> {voiceLabel}</Text> : null}
{bgCount > 0 ? <Text color={t.color.dim}> {bgCount} bg</Text> : null}
</Text>
</Box>
<Text color={t.color.bronze as any}> </Text>
<Text color={t.color.label as any}>{cwdLabel}</Text>
<Text color={t.color.bronze}> </Text>
<Text color={t.color.label}>{cwdLabel}</Text>
</Box>
)
}
@ -139,7 +140,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri
return (
<Box
alignSelf="flex-start"
borderColor={color as any}
borderColor={color}
borderStyle="double"
flexDirection="column"
marginTop={1}
@ -247,21 +248,21 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
width={1}
>
{!scrollable ? (
<Text color={trackColor as any} dim>
<Text color={trackColor} dim>
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
</Text>
) : (
<>
{thumbTop > 0 ? (
<Text color={trackColor as any} dim={!hover}>
<Text color={trackColor} dim={!hover}>
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
</Text>
) : null}
{thumb > 0 ? (
<Text color={thumbColor as any}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
) : null}
{vp - thumbTop - thumb > 0 ? (
<Text color={trackColor as any} dim={!hover}>
<Text color={trackColor} dim={!hover}>
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
</Text>
) : null}

View file

@ -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 && (
<Text color={ui.theme.color.dim as any}>
<Text color={ui.theme.color.dim}>
{ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running
</Text>
)}
{status.showStickyPrompt ? (
<Text color={ui.theme.color.dim as any} wrap="truncate-end">
<Text color={ui.theme.color.label as any}> </Text>
<Text color={ui.theme.color.dim} wrap="truncate-end">
<Text color={ui.theme.color.label}> </Text>
{status.stickyPrompt}
</Text>
@ -169,19 +169,19 @@ const ComposerPane = memo(function ComposerPane({
{composer.inputBuf.map((line, i) => (
<Box key={i}>
<Box width={3}>
<Text color={ui.theme.color.dim as any}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
<Text color={ui.theme.color.dim}>{i === 0 ? `${ui.theme.brand.prompt} ` : ' '}</Text>
</Box>
<Text color={ui.theme.color.cornsilk as any}>{line || ' '}</Text>
<Text color={ui.theme.color.cornsilk}>{line || ' '}</Text>
</Box>
))}
<Box position="relative">
<Box width={pw}>
{sh ? (
<Text color={ui.theme.color.shellDollar as any}>$ </Text>
<Text color={ui.theme.color.shellDollar}>$ </Text>
) : (
<Text bold color={ui.theme.color.gold as any}>
<Text bold color={ui.theme.color.gold}>
{composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `}
</Text>
)}
@ -204,7 +204,7 @@ const ComposerPane = memo(function ComposerPane({
</Box>
)}
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim as any}> {ui.status}</Text>}
{!composer.empty && !ui.sid && <Text color={ui.theme.color.dim}> {ui.status}</Text>}
</NoSelect>
)
})

View file

@ -112,7 +112,7 @@ export function AppOverlays({
<Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={ui.theme.color.gold as any}>
<Text bold color={ui.theme.color.gold}>
{overlay.pager.title}
</Text>
</Box>
@ -123,7 +123,7 @@ export function AppOverlays({
))}
<Box marginTop={1}>
<Text color={ui.theme.color.dim as any}>
<Text color={ui.theme.color.dim}>
{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 (
<Box
backgroundColor={active ? (ui.theme.color.completionCurrentBg as any) : undefined}
backgroundColor={active ? ui.theme.color.completionCurrentBg : undefined}
flexDirection="row"
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
>
<Text bold={active} color={ui.theme.color.bronze as any}>
<Text bold={active} color={ui.theme.color.bronze}>
{' '}
{item.display}
</Text>
{item.meta ? <Text color={ui.theme.color.dim as any}> {item.meta}</Text> : null}
{item.meta ? <Text color={ui.theme.color.dim}> {item.meta}</Text> : null}
</Box>
)
})}

View file

@ -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'

View file

@ -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.
*
* <Fg c="amber">hi</Fg> // amber
* <Fg c="dim" dim></Fg> // dim cornsilk
* <Fg literal="#ff00ff">x</Fg> // raw hex
*/
export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) {
const { theme } = useStore($uiState)
return (
<Text
bold={bold}
color={literal ?? (c && theme.color[c])}
dimColor={dim}
italic={italic}
strikethrough={strikethrough}
underline={underline}
wrap={wrap}
>
{children}
</Text>
)
}

View file

@ -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 (
<Box>
<NoSelect flexShrink={0} fromLeftEdge width={lead.length}>
<Text color={(stemColor ?? t.color.dim) as any} dim={stemDim}>
<Text color={stemColor ?? t.color.dim} dim={stemDim}>
{lead}
</Text>
</NoSelect>
@ -84,11 +84,11 @@ function TreeTextRow({
wrap?: 'truncate-end' | 'wrap' | 'wrap-trim'
}) {
const text = dimColor ? (
<Text color={color as any} dim wrap={wrap}>
<Text color={color} dim wrap={wrap}>
{content}
</Text>
) : (
<Text color={color as any} wrap={wrap}>
<Text color={color} wrap={wrap}>
{content}
</Text>
)
@ -144,7 +144,7 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?:
return () => clearInterval(id)
}, [spin])
return <Text color={color as any}>{spin.frames[frame]}</Text>
return <Text color={color}>{spin.frames[frame]}</Text>
}
interface DetailRow {
@ -195,11 +195,11 @@ function StreamCursor({
}
return dimColor ? (
<Text color={color as any} dim>
<Text color={color} dim>
{streaming && on ? '▍' : ' '}
</Text>
) : (
<Text color={color as any}>{streaming && on ? '▍' : ' '}</Text>
<Text color={color}>{streaming && on ? '▍' : ' '}</Text>
)
}
@ -224,12 +224,12 @@ function Chevron({
return (
<Box onClick={(e: any) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
<Text color={color as any} dim={tone === 'dim'}>
<Text color={t.color.amber as any}>{open ? '▾ ' : '▸ '}</Text>
<Text color={color} dim={tone === 'dim'}>
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
{title}
{typeof count === 'number' ? ` (${count})` : ''}
{suffix ? (
<Text color={t.color.statusFg as any} dim>
<Text color={t.color.statusFg} dim>
{' '}
{suffix}
</Text>
@ -366,7 +366,7 @@ function SubagentAccordion({
color={t.color.cornsilk}
content={
<>
<Text color={t.color.amber as any}> </Text>
<Text color={t.color.amber}> </Text>
{line}
</>
}
@ -501,7 +501,7 @@ export const Thinking = memo(function Thinking({
{preview ? (
mode === 'full' ? (
lines.map((line, index) => (
<Text color={t.color.dim as any} dim key={index} wrap="wrap-trim">
<Text color={t.color.dim} dim key={index} wrap="wrap-trim">
{line || ' '}
{index === lines.length - 1 ? (
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
@ -509,13 +509,13 @@ export const Thinking = memo(function Thinking({
</Text>
))
) : (
<Text color={t.color.dim as any} dim wrap="truncate-end">
<Text color={t.color.dim} dim wrap="truncate-end">
{preview}
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text>
)
) : (
<Text color={t.color.dim as any} dim>
<Text color={t.color.dim} dim>
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text>
)}
@ -715,7 +715,7 @@ export const ToolTrail = memo(function ToolTrail({
return alerts.length ? (
<Box flexDirection="column">
{alerts.map(i => (
<Text color={(i.tone === 'error' ? t.color.error : t.color.warn) as any} key={`ha-${i.id}`}>
<Text color={i.tone === 'error' ? t.color.error : t.color.warn} key={`ha-${i.id}`}>
{i.tone === 'error' ? '✗' : '!'} {i.text}
</Text>
))}
@ -773,19 +773,19 @@ export const ToolTrail = memo(function ToolTrail({
}
}}
>
<Text color={t.color.dim as any} dim={!thinkingLive}>
<Text color={t.color.amber as any}>{detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
<Text color={t.color.dim} dim={!thinkingLive}>
<Text color={t.color.amber}>{detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '}</Text>
{thinkingLive ? (
<Text bold color={t.color.cornsilk as any}>
<Text bold color={t.color.cornsilk}>
Thinking
</Text>
) : (
<Text color={t.color.dim as any} dim>
<Text color={t.color.dim} dim>
Thinking
</Text>
)}
{thinkingTokensLabel ? (
<Text color={t.color.statusFg as any} dim>
<Text color={t.color.statusFg} dim>
{' '}
{thinkingTokensLabel}
</Text>
@ -843,7 +843,7 @@ export const ToolTrail = memo(function ToolTrail({
color={group.color}
content={
<>
<Text color={t.color.amber as any}> </Text>
<Text color={t.color.amber}> </Text>
{group.content}
</>
}
@ -952,7 +952,7 @@ export const ToolTrail = memo(function ToolTrail({
color={t.color.statusFg}
content={
<>
<Text color={t.color.amber as any}>Σ </Text>
<Text color={t.color.amber}>Σ </Text>
{totalTokensLabel}
</>
}

5
ui-tui/src/config/env.ts Normal file
View file

@ -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()
)

View file

@ -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

View file

@ -0,0 +1,2 @@
export const STREAM_BATCH_MS = 16
export const REASONING_PULSE_MS = 700

View file

@ -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<Role, (t: Theme) => { 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<string, string> = {
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 }

View file

@ -0,0 +1 @@
export const LONG_RUN_CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…']

View file

@ -0,0 +1,17 @@
export const FACES = [
'(。•́︿•̀。)',
'(◔_◔)',
'(¬‿¬)',
'( •_•)>⌐■-■',
'(⌐■_■)',
'(´・_・`)',
'◉_◉',
'(°ロ°)',
'( ˘⌣˘)♡',
'ヽ(>∀<☆)☆',
'٩(๑❛ᴗ❛๑)۶',
'(⊙_⊙)',
'(¬_¬)',
'( ͡° ͜ʖ ͡°)',
'ಠ_ಠ'
]

View file

@ -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()}`))

View file

@ -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']
]

View file

@ -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)

View file

@ -0,0 +1,38 @@
export const TOOL_VERBS: Record<string, string> = {
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'
]

View file

@ -0,0 +1,29 @@
import type { DetailsMode } from '../types.js'
const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded']
const THINKING_FALLBACK: Record<string, DetailsMode> = {
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]!

View file

@ -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`
}

View file

@ -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))}`
}

View file

@ -0,0 +1,9 @@
import type { Theme } from '../theme.js'
import type { Role } from '../types.js'
export const ROLE: Record<Role, (t: Theme) => { 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 })
}

View file

@ -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()
}
}

View file

@ -0,0 +1,3 @@
import type { Usage } from '../types.js'
export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 }

View file

@ -0,0 +1,44 @@
import type { Msg } from '../types.js'
import { userDisplay } from './messages.js'
const upperBound = (offsets: ArrayLike<number>, 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<number>,
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 ''
}

View file

@ -20,6 +20,8 @@ export interface GatewayTranscriptMessage {
text?: string
}
// ── Commands / completion ────────────────────────────────────────────
export interface CommandsCatalogResponse {
canon?: Record<string, string>
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<string, string[]>
}
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

View file

@ -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 = <T>(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)

View file

@ -0,0 +1,7 @@
export const INTERPOLATION_RE = /\{!(.+?)\}/g
export const hasInterpolation = (s: string) => {
INTERPOLATION_RE.lastIndex = 0
return INTERPOLATION_RE.test(s)
}

View file

@ -0,0 +1 @@
export const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g