feat: add TUI session orchestrator
Add a first-class active-session orchestrator for the Ink TUI: - list, activate, close, and launch live process-local TUI sessions - hydrate committed and in-flight output when switching sessions - dispatch a new prompt session from the +new row with session-scoped model picks - expose a clickable live-session count in the status chrome - preserve stable row order while initially focusing the current session - support mouse hit-testing for floating orchestrator overlays - add backend and frontend regression coverage for the lifecycle and UI helpers
This commit is contained in:
@@ -3,7 +3,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea
|
||||
|
||||
import type { PasteEvent } from '../components/textInput.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type { ImageAttachResponse } from '../gatewayTypes.js'
|
||||
import type { ImageAttachResponse, SessionCloseResponse } from '../gatewayTypes.js'
|
||||
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
|
||||
import type { RpcResult } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
@@ -79,6 +79,7 @@ export interface OverlayState {
|
||||
pager: null | PagerState
|
||||
picker: boolean
|
||||
secret: null | SecretReq
|
||||
sessions: boolean
|
||||
skillsHub: boolean
|
||||
sudo: null | SudoReq
|
||||
}
|
||||
@@ -103,6 +104,7 @@ export interface UiState {
|
||||
detailsMode: DetailsMode
|
||||
detailsModeCommandOverride: boolean
|
||||
info: null | SessionInfo
|
||||
liveSessionCount: number
|
||||
inlineDiffs: boolean
|
||||
mouseTracking: MouseTrackingMode
|
||||
pasteCollapseLines: number
|
||||
@@ -284,6 +286,7 @@ export interface SlashHandlerContext {
|
||||
die: () => void
|
||||
dieWithCode: (code: number) => void
|
||||
guardBusySessionSwitch: (what?: string) => boolean
|
||||
newLiveSession: (msg?: string, title?: string) => void
|
||||
newSession: (msg?: string, title?: string) => void
|
||||
resetVisibleHistory: (info?: null | SessionInfo) => void
|
||||
resumeById: (id: string) => void
|
||||
@@ -311,6 +314,10 @@ export interface AppLayoutActions {
|
||||
answerSecret: (value: string) => void
|
||||
answerSudo: (pw: string) => void
|
||||
clearSelection: () => void
|
||||
activateLiveSession: (id: string) => void
|
||||
closeLiveSession: (id: string) => Promise<null | SessionCloseResponse>
|
||||
newLiveSession: () => void
|
||||
newPromptSession: (prompt: string, modelArg?: string) => void
|
||||
onModelSelect: (value: string) => void
|
||||
resumeById: (id: string) => void
|
||||
setStickyPrompt: (value: string) => void
|
||||
@@ -369,7 +376,11 @@ export interface AppOverlaysProps {
|
||||
completions: CompletionItem[]
|
||||
onApprovalChoice: (choice: string) => void
|
||||
onClarifyAnswer: (value: string) => void
|
||||
onActiveSessionSelect: (sessionId: string) => void
|
||||
onActiveSessionClose: (sessionId: string) => Promise<null | SessionCloseResponse>
|
||||
onModelSelect: (value: string) => void
|
||||
onNewLiveSession: () => void
|
||||
onNewPromptSession: (prompt: string, modelArg?: string) => void
|
||||
onPickerSelect: (sessionId: string) => void
|
||||
onSecretSubmit: (value: string) => void
|
||||
onSudoSubmit: (pw: string) => void
|
||||
|
||||
@@ -12,6 +12,7 @@ const buildOverlayState = (): OverlayState => ({
|
||||
pager: null,
|
||||
picker: false,
|
||||
secret: null,
|
||||
sessions: false,
|
||||
skillsHub: false,
|
||||
sudo: null
|
||||
})
|
||||
@@ -20,8 +21,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
|
||||
|
||||
export const $isBlocked = computed(
|
||||
$overlayState,
|
||||
({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
|
||||
Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
|
||||
({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, sessions, skillsHub, sudo }) =>
|
||||
Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || sessions || skillsHub || sudo)
|
||||
)
|
||||
|
||||
export const getOverlayState = () => $overlayState.get()
|
||||
@@ -47,5 +48,6 @@ export const resetFlowOverlays = () =>
|
||||
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
|
||||
modelPicker: $overlayState.get().modelPicker,
|
||||
picker: $overlayState.get().picker,
|
||||
sessions: $overlayState.get().sessions,
|
||||
skillsHub: $overlayState.get().skillsHub
|
||||
})
|
||||
|
||||
@@ -93,15 +93,15 @@ export const sessionCommands: SlashCommand[] = [
|
||||
},
|
||||
|
||||
{
|
||||
help: 'browse and resume previous sessions',
|
||||
aliases: ['switch'],
|
||||
help: 'switch between live TUI sessions',
|
||||
name: 'sessions',
|
||||
run: (arg, ctx) => {
|
||||
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
|
||||
return
|
||||
}
|
||||
if (!arg.trim()) {
|
||||
return patchOverlayState({ picker: true })
|
||||
if (arg.trim().toLowerCase() === 'new') {
|
||||
return ctx.session.newLiveSession()
|
||||
}
|
||||
|
||||
patchOverlayState({ sessions: true })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -757,6 +757,14 @@ class TurnController {
|
||||
}, this.streamDelay)
|
||||
}
|
||||
|
||||
hydrateStreamingText(text: string) {
|
||||
this.streamTimer = clear(this.streamTimer)
|
||||
this.bufRef = text
|
||||
const raw = this.bufRef.trimStart()
|
||||
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
|
||||
patchTurnState({ streaming: boundedLiveRenderText(visible) })
|
||||
}
|
||||
|
||||
startMessage() {
|
||||
this.endReasoningPhase()
|
||||
this.clearReasoning()
|
||||
|
||||
@@ -15,6 +15,7 @@ const buildUiState = (): UiState => ({
|
||||
detailsModeCommandOverride: false,
|
||||
indicatorStyle: DEFAULT_INDICATOR_STYLE,
|
||||
info: null,
|
||||
liveSessionCount: 0,
|
||||
inlineDiffs: true,
|
||||
mouseTracking: MOUSE_TRACKING,
|
||||
pasteCollapseLines: 5,
|
||||
|
||||
@@ -479,6 +479,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return cActions.clearIn()
|
||||
}
|
||||
|
||||
if (isCtrl(key, ch, 'x')) {
|
||||
return patchOverlayState({ sessions: true })
|
||||
}
|
||||
|
||||
if (key.ctrl && ch.toLowerCase() === 'c') {
|
||||
if (live.busy && live.sid) {
|
||||
return turnController.interruptTurn({
|
||||
|
||||
@@ -11,7 +11,10 @@ import { type GatewayClient } from '../gatewayClient.js'
|
||||
import type {
|
||||
ClarifyRespondResponse,
|
||||
ClipboardPasteResponse,
|
||||
ConfigSetResponse,
|
||||
GatewayEvent,
|
||||
SessionActiveListResponse,
|
||||
SessionCloseResponse,
|
||||
TerminalResizeResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { useGitBranch } from '../hooks/useGitBranch.js'
|
||||
@@ -70,6 +73,66 @@ const statusColorOf = (status: string, t: { error: string; muted: string; ok: st
|
||||
return t.muted
|
||||
}
|
||||
|
||||
export interface PromptLiveSessionOptions {
|
||||
dispatchSubmission: (full: string) => void
|
||||
maybeWarn: (value: unknown) => void
|
||||
modelArg?: string
|
||||
newLiveSession: (msg?: string, title?: string) => Promise<null | string> | null | string | void
|
||||
onModelSwitched?: (value: string, result: ConfigSetResponse) => void
|
||||
prompt: string
|
||||
rpc: GatewayRpc
|
||||
sys: (text: string) => void
|
||||
}
|
||||
|
||||
export async function startPromptLiveSession({
|
||||
dispatchSubmission,
|
||||
maybeWarn,
|
||||
modelArg,
|
||||
newLiveSession,
|
||||
onModelSwitched,
|
||||
prompt,
|
||||
rpc,
|
||||
sys
|
||||
}: PromptLiveSessionOptions) {
|
||||
const trimmed = prompt.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Let the backend-created session key (YYYYMMDD_HHMMSS_xxxxxx) remain
|
||||
// the initial title. Auto-title generation can rename it after the first
|
||||
// response; pre-queuing prompt text here causes duplicate-title errors when
|
||||
// users dispatch common prompts like "Hello, what model are you?".
|
||||
const sid = (await newLiveSession('new live session started')) ?? null
|
||||
|
||||
if (!sid) {
|
||||
sys('error: failed to start new live session')
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const requestedModel = modelArg?.trim()
|
||||
|
||||
if (requestedModel) {
|
||||
const result = await rpc<ConfigSetResponse>('config.set', { key: 'model', session_id: sid, value: requestedModel })
|
||||
|
||||
if (!result?.value) {
|
||||
sys('error: invalid response: model switch')
|
||||
|
||||
return sid
|
||||
}
|
||||
|
||||
sys(`model → ${result.value}`)
|
||||
maybeWarn(result)
|
||||
onModelSwitched?.(result.value, result)
|
||||
}
|
||||
|
||||
dispatchSubmission(trimmed)
|
||||
|
||||
return sid
|
||||
}
|
||||
|
||||
export function useMainApp(gw: GatewayClient) {
|
||||
const { exit } = useApp()
|
||||
const { stdout } = useStdout()
|
||||
@@ -429,6 +492,36 @@ export function useMainApp(gw: GatewayClient) {
|
||||
|
||||
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid })
|
||||
|
||||
useEffect(() => {
|
||||
if (!ui.sid) {
|
||||
patchUiState({ liveSessionCount: 0 })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let stopped = false
|
||||
|
||||
const refresh = () => {
|
||||
gw.request<SessionActiveListResponse>('session.active_list', { current_session_id: getUiState().sid })
|
||||
.then(raw => {
|
||||
const result = asRpcResult<SessionActiveListResponse>(raw)
|
||||
|
||||
if (!stopped && result?.sessions) {
|
||||
patchUiState({ liveSessionCount: result.sessions.length })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
refresh()
|
||||
const timer = setInterval(refresh, 1500)
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [gw, ui.sid])
|
||||
|
||||
// Tab title: `⚠` waiting on approval/sudo/secret/clarify, `⏳` busy, `✓` idle.
|
||||
const model = ui.info?.model?.replace(/^.*\//, '') ?? ''
|
||||
|
||||
@@ -683,6 +776,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
die,
|
||||
dieWithCode,
|
||||
guardBusySessionSwitch: session.guardBusySessionSwitch,
|
||||
newLiveSession: session.newLiveSession,
|
||||
newSession: session.newSession,
|
||||
resetVisibleHistory: session.resetVisibleHistory,
|
||||
resumeById: session.resumeById,
|
||||
@@ -690,7 +784,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
},
|
||||
slashFlightRef,
|
||||
transcript: { page, panel, send, setHistoryItems, sys, trimLastExchange: session.trimLastExchange },
|
||||
voice: { setVoiceEnabled, setVoiceRecordKey }
|
||||
voice: { setVoiceEnabled, setVoiceRecordKey, setVoiceTts }
|
||||
}),
|
||||
[
|
||||
catalog,
|
||||
@@ -760,6 +854,46 @@ export function useMainApp(gw: GatewayClient) {
|
||||
slashRef.current(`/model ${value}`)
|
||||
}, [])
|
||||
|
||||
const closeLiveSession = useCallback(
|
||||
async (id: string) => {
|
||||
patchUiState({ status: 'closing session…' })
|
||||
|
||||
try {
|
||||
const result = (await session.closeSession(id)) as null | SessionCloseResponse
|
||||
patchUiState({ status: 'ready' })
|
||||
|
||||
return result
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
sys(`error: ${message}`)
|
||||
patchUiState({ status: 'ready' })
|
||||
|
||||
throw e
|
||||
}
|
||||
},
|
||||
[session, sys]
|
||||
)
|
||||
|
||||
const newPromptSession = useCallback(
|
||||
(prompt: string, modelArg?: string) => {
|
||||
void startPromptLiveSession({
|
||||
dispatchSubmission,
|
||||
maybeWarn,
|
||||
modelArg,
|
||||
newLiveSession: session.newLiveSession,
|
||||
onModelSwitched: value =>
|
||||
patchUiState(state => ({
|
||||
...state,
|
||||
info: state.info ? { ...state.info, model: value } : { model: value, skills: {}, tools: {} }
|
||||
})),
|
||||
prompt,
|
||||
rpc,
|
||||
sys
|
||||
})
|
||||
},
|
||||
[dispatchSubmission, maybeWarn, rpc, session.newLiveSession, sys]
|
||||
)
|
||||
|
||||
const hasReasoning = useTurnSelector(state => Boolean(state.reasoning.trim()))
|
||||
|
||||
// Per-section overrides win over the global mode — when every section is
|
||||
@@ -813,16 +947,32 @@ export function useMainApp(gw: GatewayClient) {
|
||||
|
||||
const appActions = useMemo(
|
||||
() => ({
|
||||
activateLiveSession: session.activateLiveSession,
|
||||
closeLiveSession,
|
||||
answerApproval,
|
||||
answerClarify,
|
||||
answerSecret,
|
||||
answerSudo,
|
||||
clearSelection,
|
||||
newLiveSession: () => session.newLiveSession(),
|
||||
newPromptSession,
|
||||
onModelSelect,
|
||||
resumeById: session.resumeById,
|
||||
setStickyPrompt
|
||||
}),
|
||||
[answerApproval, answerClarify, answerSecret, answerSudo, clearSelection, onModelSelect, session.resumeById]
|
||||
[
|
||||
answerApproval,
|
||||
answerClarify,
|
||||
answerSecret,
|
||||
answerSudo,
|
||||
clearSelection,
|
||||
closeLiveSession,
|
||||
newPromptSession,
|
||||
onModelSelect,
|
||||
session.activateLiveSession,
|
||||
session.newLiveSession,
|
||||
session.resumeById
|
||||
]
|
||||
)
|
||||
|
||||
const appComposer = useMemo(
|
||||
|
||||
@@ -2,15 +2,17 @@ import { writeFileSync } from 'node:fs'
|
||||
|
||||
import type { ScrollBoxHandle } from '@hermes/ink'
|
||||
import { evictInkCaches } from '@hermes/ink'
|
||||
import { useCallback, type RefObject } from 'react'
|
||||
import { type RefObject, useCallback } from 'react'
|
||||
|
||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
|
||||
import { ZERO } from '../domain/usage.js'
|
||||
import { type GatewayClient } from '../gatewayClient.js'
|
||||
import type {
|
||||
SessionActivateResponse,
|
||||
SessionCloseResponse,
|
||||
SessionCreateResponse,
|
||||
SessionInflightTurn,
|
||||
SessionResumeResponse,
|
||||
SessionTitleResponse,
|
||||
SetupStatusResponse
|
||||
@@ -26,6 +28,18 @@ import { getUiState, patchUiState } from './uiStore.js'
|
||||
|
||||
const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO)
|
||||
|
||||
const statusFromLiveSession = (status?: string, running = false) => {
|
||||
if (status === 'waiting') {
|
||||
return 'waiting for input…'
|
||||
}
|
||||
|
||||
if (status === 'starting') {
|
||||
return 'starting agent…'
|
||||
}
|
||||
|
||||
return running || status === 'working' ? 'running…' : 'ready'
|
||||
}
|
||||
|
||||
export const writeActiveSessionFile = (sessionId: null | string, file = process.env.HERMES_TUI_ACTIVE_SESSION_FILE) => {
|
||||
if (!file || !sessionId) {
|
||||
return
|
||||
@@ -38,6 +52,22 @@ export const writeActiveSessionFile = (sessionId: null | string, file = process.
|
||||
}
|
||||
}
|
||||
|
||||
export const liveSessionInflightMessages = (inflight?: null | SessionInflightTurn): Msg[] => {
|
||||
const user = String(inflight?.user ?? '').trim()
|
||||
|
||||
return user ? [{ role: 'user', text: user }] : []
|
||||
}
|
||||
|
||||
export const hydrateLiveSessionInflight = (inflight?: null | SessionInflightTurn) => {
|
||||
const assistant = String(inflight?.assistant ?? '')
|
||||
|
||||
if (!assistant && !inflight?.streaming) {
|
||||
return
|
||||
}
|
||||
|
||||
turnController.hydrateStreamingText(assistant)
|
||||
}
|
||||
|
||||
const trimTail = (items: Msg[]) => {
|
||||
const q = [...items]
|
||||
|
||||
@@ -122,23 +152,27 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
||||
[composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt]
|
||||
)
|
||||
|
||||
const newSession = useCallback(
|
||||
async (msg?: string, title?: string) => {
|
||||
const startNewSession = useCallback(
|
||||
async (msg?: string, title?: string, keepCurrent = false) => {
|
||||
const setup = await rpc<SetupStatusResponse>('setup.status', {})
|
||||
|
||||
if (setup?.provider_configured === false) {
|
||||
panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections())
|
||||
patchUiState({ status: 'setup required' })
|
||||
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
await closeSession(getUiState().sid)
|
||||
if (!keepCurrent) {
|
||||
await closeSession(getUiState().sid)
|
||||
}
|
||||
|
||||
const r = await rpc<SessionCreateResponse>('session.create', { cols: colsRef.current })
|
||||
|
||||
if (!r) {
|
||||
return patchUiState({ status: 'ready' })
|
||||
patchUiState({ status: 'ready' })
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const info = r.info ?? null
|
||||
@@ -194,10 +228,67 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
||||
sys(`warning: failed to set session title: ${message}`)
|
||||
})
|
||||
}
|
||||
|
||||
return r.session_id
|
||||
},
|
||||
[closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
|
||||
)
|
||||
|
||||
const newSession = useCallback(
|
||||
(msg?: string, title?: string) => startNewSession(msg, title, false),
|
||||
[startNewSession]
|
||||
)
|
||||
|
||||
const newLiveSession = useCallback(
|
||||
(msg = 'new live session started', title?: string) => {
|
||||
patchOverlayState({ sessions: false })
|
||||
|
||||
return startNewSession(msg, title, true)
|
||||
},
|
||||
[startNewSession]
|
||||
)
|
||||
|
||||
const activateLiveSession = useCallback(
|
||||
(id: string) => {
|
||||
patchOverlayState({ sessions: false })
|
||||
patchUiState({ status: 'switching session…' })
|
||||
|
||||
gw.request<SessionActivateResponse>('session.activate', { session_id: id })
|
||||
.then(raw => {
|
||||
const r = asRpcResult<SessionActivateResponse>(raw)
|
||||
|
||||
if (!r) {
|
||||
sys('error: invalid response: session.activate')
|
||||
|
||||
return patchUiState({ status: 'ready' })
|
||||
}
|
||||
|
||||
const info = r.info ?? null
|
||||
const running = Boolean(r.running || r.status === 'working' || r.status === 'waiting')
|
||||
|
||||
resetSession()
|
||||
setSessionStartedAt(r.started_at ? r.started_at * 1000 : Date.now())
|
||||
const transcript = [...toTranscriptMessages(r.messages), ...liveSessionInflightMessages(r.inflight)]
|
||||
setHistoryItems(info ? [introMsg(info), ...transcript] : transcript)
|
||||
writeActiveSessionFile(r.session_key ?? r.session_id)
|
||||
patchUiState({
|
||||
busy: running,
|
||||
info,
|
||||
sid: r.session_id,
|
||||
status: statusFromLiveSession(r.status, running),
|
||||
usage: usageFrom(info)
|
||||
})
|
||||
hydrateLiveSessionInflight(r.inflight)
|
||||
setTimeout(() => scrollRef.current?.scrollToBottom(), 0)
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
sys(`error: ${e.message}`)
|
||||
patchUiState({ status: 'ready' })
|
||||
})
|
||||
},
|
||||
[gw, resetSession, scrollRef, setHistoryItems, setSessionStartedAt, sys]
|
||||
)
|
||||
|
||||
const resumeById = useCallback(
|
||||
(id: string) => {
|
||||
patchOverlayState({ picker: false })
|
||||
@@ -262,8 +353,10 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
||||
)
|
||||
|
||||
return {
|
||||
activateLiveSession,
|
||||
closeSession,
|
||||
guardBusySessionSwitch,
|
||||
newLiveSession,
|
||||
newSession,
|
||||
resetSession,
|
||||
resetVisibleHistory,
|
||||
|
||||
Reference in New Issue
Block a user