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
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_THEME } from '../theme.js'
import type { SessionActiveItem } from '../gatewayTypes.js'
import {
activeSessionCountLabel,
canTypeOrchestratorPrompt,
currentSessionSelectionIndex,
orchestratorContextHint,
orchestratorContextHintSegments,
orchestratorGlobalHotkeyHint,
orchestratorGlobalHotkeyHintSegments,
orchestratorHintSegmentColor,
clampOrchestratorSelection,
closeFallbackAfterClose,
draftModelArgFromPickerValue,
draftModelDisplayLabel,
fixedSessionColumnStyle,
draftTitleFromPrompt,
isNewSessionRow,
newSessionMarkerColor,
newSessionRowIndex,
orchestratorRowClickAction,
orchestratorVisibleRowIndexes,
selectedSessionRowStyle
} from '../components/activeSessionSwitcher.js'
describe('session orchestrator helpers', () => {
it('labels live sessions compactly for tight overlays', () => {
expect(activeSessionCountLabel(0)).toBe('0 live sessions')
expect(activeSessionCountLabel(1)).toBe('1 live session')
expect(activeSessionCountLabel(3)).toBe('3 live sessions')
expect(activeSessionCountLabel(1)).not.toContain('in this TUI')
})
it('keeps session orchestrator hotkey hints short and contextual', () => {
expect(orchestratorContextHint(false)).toBe('Session row: Enter switch · Ctrl+D close')
expect(orchestratorContextHint(true)).toBe('New row: type prompt · Enter start · Tab model')
expect(orchestratorGlobalHotkeyHint).toBe('↑↓ move · Ctrl+N new · Ctrl+R refresh · Esc close')
expect(orchestratorGlobalHotkeyHint.length).toBeLessThanOrEqual(56)
})
it('assigns themed colors consistently to orchestrator labels and hotkeys', () => {
expect(orchestratorContextHintSegments(false)).toEqual([
{ role: 'label', text: 'Session row:' },
{ role: 'text', text: ' ' },
{ role: 'hotkey', text: 'Enter' },
{ role: 'text', text: ' switch · ' },
{ role: 'hotkey', text: 'Ctrl+D' },
{ role: 'text', text: ' close' }
])
expect(orchestratorContextHintSegments(true)).toEqual([
{ role: 'label', text: 'New row:' },
{ role: 'text', text: ' type prompt · ' },
{ role: 'hotkey', text: 'Enter' },
{ role: 'text', text: ' start · ' },
{ role: 'hotkey', text: 'Tab' },
{ role: 'text', text: ' model' }
])
expect(orchestratorGlobalHotkeyHintSegments.filter(s => s.role === 'hotkey').map(s => s.text)).toEqual([
'↑↓',
'Ctrl+N',
'Ctrl+R',
'Esc'
])
expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'hotkey')).toBe(DEFAULT_THEME.color.accent)
expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'label')).toBe(DEFAULT_THEME.color.label)
expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'text')).toBe(DEFAULT_THEME.color.muted)
expect(newSessionMarkerColor(DEFAULT_THEME, false)).toBe(DEFAULT_THEME.color.label)
expect(newSessionMarkerColor(DEFAULT_THEME, true)).toBe(DEFAULT_THEME.color.text)
})
it('uses a readable selected row style instead of accent-on-accent inverse text', () => {
const style = selectedSessionRowStyle(DEFAULT_THEME)
expect(style.backgroundColor).toBe(DEFAULT_THEME.color.selectionBg)
expect(style.color).toBe(DEFAULT_THEME.color.text)
expect(style.backgroundColor).not.toBe(DEFAULT_THEME.color.accent)
expect(style.color).not.toBe(DEFAULT_THEME.color.accent)
})
it('turns model picker values into session-scoped draft model args', () => {
expect(draftModelArgFromPickerValue('kimi-k2.6 --provider ollama-cloud --tui-session')).toBe(
'kimi-k2.6 --provider ollama-cloud'
)
expect(draftModelArgFromPickerValue('openai/gpt-5.5 --provider openai-codex --global')).toBe(
'openai/gpt-5.5 --provider openai-codex'
)
})
it('highlights the current live session when the picker opens', () => {
const sessions = [
{ id: 'first', status: 'idle' },
{ id: 'second', status: 'working', current: true },
{ id: 'third', status: 'idle' }
] satisfies SessionActiveItem[]
expect(currentSessionSelectionIndex(sessions, 'second')).toBe(1)
expect(
currentSessionSelectionIndex([{ id: 'first', status: 'idle' }, { id: 'third', status: 'idle' }], 'third')
).toBe(1)
expect(currentSessionSelectionIndex(sessions, 'missing')).toBe(1)
expect(currentSessionSelectionIndex([], 'missing')).toBe(0)
})
it('adds a selectable New row after the live sessions and gates prompt typing to it', () => {
expect(newSessionRowIndex(0)).toBe(0)
expect(newSessionRowIndex(3)).toBe(3)
expect(clampOrchestratorSelection(-5, 2)).toBe(0)
expect(clampOrchestratorSelection(99, 2)).toBe(2)
expect(isNewSessionRow(0, 0)).toBe(true)
expect(isNewSessionRow(1, 2)).toBe(false)
expect(isNewSessionRow(2, 2)).toBe(true)
expect(canTypeOrchestratorPrompt(1, 2)).toBe(false)
expect(canTypeOrchestratorPrompt(2, 2)).toBe(true)
expect(orchestratorVisibleRowIndexes(3, 3, 12)).toEqual([0, 1, 2, 3])
expect(orchestratorVisibleRowIndexes(13, 13, 12)).toContain(13)
})
it('selects a safe fallback after closing the current live session', () => {
const remaining = [
{ id: 'next', status: 'idle' },
{ id: 'other', status: 'working' }
] satisfies SessionActiveItem[]
expect(closeFallbackAfterClose('other', 'current', remaining)).toEqual({ action: 'stay' })
expect(closeFallbackAfterClose('current', 'current', remaining)).toEqual({ action: 'activate', sessionId: 'next' })
expect(closeFallbackAfterClose('current', 'current', [])).toEqual({ action: 'new' })
})
it('shows clean draft model labels without picker flags or provider params', () => {
expect(draftModelDisplayLabel('kimi-k2.6 --provider ollama-cloud --tui-session')).toBe('kimi-k2.6')
expect(draftModelDisplayLabel('openai/gpt-5.5 --provider openai-codex --global')).toBe('gpt-5.5')
expect(draftModelDisplayLabel('')).toBe('current/default')
})
it('maps row clicks to existing-session activation or New-row focus', () => {
const sessions = [
{ id: 'a', status: 'idle' },
{ id: 'b', status: 'idle' }
] satisfies SessionActiveItem[]
expect(orchestratorRowClickAction(1, sessions)).toEqual({ action: 'activate', sessionId: 'b' })
expect(orchestratorRowClickAction(2, sessions)).toEqual({ action: 'select-new' })
expect(orchestratorRowClickAction(99, sessions)).toEqual({ action: 'select-new' })
})
it('keeps fixed table columns from shrinking into adjacent columns', () => {
expect(fixedSessionColumnStyle().flexShrink).toBe(0)
})
it('builds a compact title from the orchestrator prompt', () => {
expect(draftTitleFromPrompt(' Build the websocket orchestrator panel and make it robust. ', 24)).toBe(
'Build the websocket orc…'
)
})
})
@@ -0,0 +1,84 @@
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { StatusRule } from '../components/appChrome.js'
import { DEFAULT_THEME } from '../theme.js'
type ReactNodeLike = React.ReactNode
const textContent = (node: ReactNodeLike): string => {
if (node === null || node === undefined || typeof node === 'boolean') {
return ''
}
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map(textContent).join('')
}
if (React.isValidElement(node)) {
return textContent(node.props.children)
}
return ''
}
const findClickableWithText = (node: ReactNodeLike, needle: string): React.ReactElement | null => {
if (node === null || node === undefined || typeof node === 'boolean') {
return null
}
if (Array.isArray(node)) {
for (const child of node) {
const found = findClickableWithText(child, needle)
if (found) {
return found
}
}
return null
}
if (!React.isValidElement(node)) {
return null
}
if (typeof node.props.onClick === 'function' && textContent(node).includes(needle)) {
return node
}
return findClickableWithText(node.props.children, needle)
}
describe('StatusRule session count click target', () => {
it('makes the live session count itself clickable', () => {
const openSwitcher = vi.fn()
const element = StatusRule({
bgCount: 0,
busy: false,
cols: 100,
cwdLabel: '~/repo',
liveSessionCount: 1,
model: 'kimi-k2.6',
onSessionCountClick: openSwitcher,
sessionStartedAt: null,
showCost: false,
status: 'ready',
statusColor: DEFAULT_THEME.color.ok,
t: DEFAULT_THEME,
turnStartedAt: null,
usage: { total: 0 },
voiceLabel: ''
})
const clickableSessionCount = findClickableWithText(element, '1 session')
expect(clickableSessionCount).not.toBeNull()
clickableSessionCount!.props.onClick({ stopImmediatePropagation: vi.fn() })
expect(openSwitcher).toHaveBeenCalledOnce()
})
})
@@ -18,6 +18,16 @@ describe('createSlashHandler', () => {
expect(getOverlayState().picker).toBe(true)
})
it('opens the live session switcher locally even when the current session is busy', () => {
patchUiState({ busy: true, sid: 'sid-abc' })
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/sessions')).toBe(true)
expect(getOverlayState().sessions).toBe(true)
expect(ctx.session.guardBusySessionSwitch).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('handles /redraw locally without slash worker fallback', () => {
const ctx = buildCtx()
@@ -779,6 +789,7 @@ const buildSession = () => ({
die: vi.fn(),
dieWithCode: vi.fn(),
guardBusySessionSwitch: vi.fn(() => false),
newLiveSession: vi.fn(),
newSession: vi.fn(),
resetVisibleHistory: vi.fn(),
resumeById: vi.fn(),
@@ -796,7 +807,8 @@ const buildTranscript = () => ({
const buildVoice = () => ({
setVoiceEnabled: vi.fn(),
setVoiceRecordKey: vi.fn()
setVoiceRecordKey: vi.fn(),
setVoiceTts: vi.fn()
})
interface Ctx {
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { startPromptLiveSession } from '../app/useMainApp.js'
describe('startPromptLiveSession', () => {
it('starts a kept-live session with generated id/title, applies selected model, then dispatches the prompt', async () => {
const calls: Array<[string, unknown]> = []
const sid = await startPromptLiveSession({
dispatchSubmission: prompt => calls.push(['dispatch', prompt]),
maybeWarn: value => calls.push(['warn', value]),
modelArg: 'kimi-k2.6 --provider ollama-cloud',
newLiveSession: async (message, title) => {
calls.push(['new', { message, title }])
return 'abc123'
},
onModelSwitched: (value, result) => calls.push(['model-switched', { result, value }]),
prompt: ' Build the thing ',
rpc: async (method, params) => {
calls.push(['rpc', { method, params }])
return { value: 'kimi-k2.6', warning: '' }
},
sys: text => calls.push(['sys', text])
})
expect(sid).toBe('abc123')
expect(calls).toEqual([
['new', { message: 'new live session started', title: undefined }],
[
'rpc',
{
method: 'config.set',
params: { key: 'model', session_id: 'abc123', value: 'kimi-k2.6 --provider ollama-cloud' }
}
],
['sys', 'model → kimi-k2.6'],
['warn', { value: 'kimi-k2.6', warning: '' }],
['model-switched', { result: { value: 'kimi-k2.6', warning: '' }, value: 'kimi-k2.6' }],
['dispatch', 'Build the thing']
])
})
it('does not start a session for an empty prompt', async () => {
const calls: string[] = []
const sid = await startPromptLiveSession({
dispatchSubmission: () => calls.push('dispatch'),
maybeWarn: () => calls.push('warn'),
newLiveSession: async () => {
calls.push('new')
return 'abc123'
},
prompt: ' ',
rpc: async () => ({ value: 'unused' }),
sys: () => calls.push('sys')
})
expect(sid).toBeNull()
expect(calls).toEqual([])
})
})
@@ -2,9 +2,12 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { writeActiveSessionFile } from '../app/useSessionLifecycle.js'
import { turnController } from '../app/turnController.js'
import { getTurnState, resetTurnState } from '../app/turnStore.js'
import { patchUiState, resetUiState } from '../app/uiStore.js'
import { hydrateLiveSessionInflight, liveSessionInflightMessages, writeActiveSessionFile } from '../app/useSessionLifecycle.js'
describe('writeActiveSessionFile', () => {
let dir = ''
@@ -25,3 +28,33 @@ describe('writeActiveSessionFile', () => {
expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ session_id: 'actual_session' })
})
})
describe('live session activation in-flight state', () => {
beforeEach(() => {
resetUiState()
resetTurnState()
turnController.fullReset()
patchUiState({ streaming: true })
})
it('keeps the in-flight user prompt in history and hydrates partial assistant text', () => {
const inflight = { assistant: 'partial answer', streaming: true, user: 'write a long answer' }
expect(liveSessionInflightMessages(inflight)).toEqual([{ role: 'user', text: 'write a long answer' }])
hydrateLiveSessionInflight(inflight)
expect(turnController.bufRef).toBe('partial answer')
expect(getTurnState().streaming).toBe('partial answer')
})
it('ignores empty in-flight payloads', () => {
expect(liveSessionInflightMessages({ assistant: '', streaming: false, user: ' ' })).toEqual([])
hydrateLiveSessionInflight({ assistant: '', streaming: false, user: '' })
expect(turnController.bufRef).toBe('')
expect(getTurnState().streaming).toBe('')
})
})