mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
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:
parent
9c71f3a6ea
commit
68ecdb6e26
56 changed files with 3666 additions and 4117 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ''
|
||||
}
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
293
ui-tui/src/app/slash/commands/core.ts
Normal file
293
ui-tui/src/app/slash/commands/core.ts
Normal 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)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
368
ui-tui/src/app/slash/commands/ops.ts
Normal file
368
ui-tui/src/app/slash/commands/ops.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
]
|
||||
462
ui-tui/src/app/slash/commands/session.ts
Normal file
462
ui-tui/src/app/slash/commands/session.ts
Normal 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}`]
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
18
ui-tui/src/app/slash/registry.ts
Normal file
18
ui-tui/src/app/slash/registry.ts
Normal 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())
|
||||
|
|
@ -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)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
24
ui-tui/src/app/slash/types.ts
Normal file
24
ui-tui/src/app/slash/types.ts
Normal 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
|
||||
}
|
||||
353
ui-tui/src/app/turnController.ts
Normal file
353
ui-tui/src/app/turnController.ts
Normal 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 }
|
||||
47
ui-tui/src/app/turnStore.ts
Normal file
47
ui-tui/src/app/turnStore.ts
Normal 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())
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
84
ui-tui/src/app/useConfigSync.ts
Normal file
84
ui-tui/src/app/useConfigSync.ts
Normal 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])
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
185
ui-tui/src/app/useSessionLifecycle.ts
Normal file
185
ui-tui/src/app/useSessionLifecycle.ts
Normal 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
|
||||
}
|
||||
}
|
||||
300
ui-tui/src/app/useSubmission.ts
Normal file
300
ui-tui/src/app/useSubmission.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
45
ui-tui/src/components/themed.tsx
Normal file
45
ui-tui/src/components/themed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
5
ui-tui/src/config/env.ts
Normal 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()
|
||||
)
|
||||
5
ui-tui/src/config/limits.ts
Normal file
5
ui-tui/src/config/limits.ts
Normal 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
|
||||
2
ui-tui/src/config/timing.ts
Normal file
2
ui-tui/src/config/timing.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const STREAM_BATCH_MS = 16
|
||||
export const REASONING_PULSE_MS = 700
|
||||
|
|
@ -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 }
|
||||
1
ui-tui/src/content/charms.ts
Normal file
1
ui-tui/src/content/charms.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const LONG_RUN_CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…']
|
||||
17
ui-tui/src/content/faces.ts
Normal file
17
ui-tui/src/content/faces.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export const FACES = [
|
||||
'(。•́︿•̀。)',
|
||||
'(◔_◔)',
|
||||
'(¬‿¬)',
|
||||
'( •_•)>⌐■-■',
|
||||
'(⌐■_■)',
|
||||
'(´・_・`)',
|
||||
'◉_◉',
|
||||
'(°ロ°)',
|
||||
'( ˘⌣˘)♡',
|
||||
'ヽ(>∀<☆)☆',
|
||||
'٩(๑❛ᴗ❛๑)۶',
|
||||
'(⊙_⊙)',
|
||||
'(¬_¬)',
|
||||
'( ͡° ͜ʖ ͡°)',
|
||||
'ಠ_ಠ'
|
||||
]
|
||||
40
ui-tui/src/content/fortunes.ts
Normal file
40
ui-tui/src/content/fortunes.ts
Normal 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()}`))
|
||||
19
ui-tui/src/content/hotkeys.ts
Normal file
19
ui-tui/src/content/hotkeys.ts
Normal 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']
|
||||
]
|
||||
13
ui-tui/src/content/placeholders.ts
Normal file
13
ui-tui/src/content/placeholders.ts
Normal 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)
|
||||
38
ui-tui/src/content/verbs.ts
Normal file
38
ui-tui/src/content/verbs.ts
Normal 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'
|
||||
]
|
||||
29
ui-tui/src/domain/details.ts
Normal file
29
ui-tui/src/domain/details.ts
Normal 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]!
|
||||
102
ui-tui/src/domain/messages.ts
Normal file
102
ui-tui/src/domain/messages.ts
Normal 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`
|
||||
}
|
||||
5
ui-tui/src/domain/paths.ts
Normal file
5
ui-tui/src/domain/paths.ts
Normal 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))}`
|
||||
}
|
||||
9
ui-tui/src/domain/roles.ts
Normal file
9
ui-tui/src/domain/roles.ts
Normal 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 })
|
||||
}
|
||||
25
ui-tui/src/domain/slash.ts
Normal file
25
ui-tui/src/domain/slash.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
3
ui-tui/src/domain/usage.ts
Normal file
3
ui-tui/src/domain/usage.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { Usage } from '../types.js'
|
||||
|
||||
export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 }
|
||||
44
ui-tui/src/domain/viewport.ts
Normal file
44
ui-tui/src/domain/viewport.ts
Normal 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 ''
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
7
ui-tui/src/protocol/interpolation.ts
Normal file
7
ui-tui/src/protocol/interpolation.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export const INTERPOLATION_RE = /\{!(.+?)\}/g
|
||||
|
||||
export const hasInterpolation = (s: string) => {
|
||||
INTERPOLATION_RE.lastIndex = 0
|
||||
|
||||
return INTERPOLATION_RE.test(s)
|
||||
}
|
||||
1
ui-tui/src/protocol/paste.ts
Normal file
1
ui-tui/src/protocol/paste.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g
|
||||
Loading…
Add table
Add a link
Reference in a new issue