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:
Nick
2026-05-26 20:51:59 -07:00
committed by Teknium
parent 2fc77c53f0
commit 0a83247e9f
29 changed files with 2048 additions and 105 deletions
+12 -1
View File
@@ -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
+4 -2
View File
@@ -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
})
+6 -6
View File
@@ -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 })
}
},
+8
View File
@@ -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()
+1
View File
@@ -15,6 +15,7 @@ const buildUiState = (): UiState => ({
detailsModeCommandOverride: false,
indicatorStyle: DEFAULT_INDICATOR_STYLE,
info: null,
liveSessionCount: 0,
inlineDiffs: true,
mouseTracking: MOUSE_TRACKING,
pasteCollapseLines: 5,
+4
View File
@@ -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({
+152 -2
View File
@@ -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(
+99 -6
View File
@@ -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,