import { Box, Text, useInput, useStdout } from '@hermes/ink' import { useCallback, useEffect, useRef, useState } from 'react' import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { SessionActiveItem, SessionActiveListResponse, SessionCloseResponse, SessionDeleteResponse, SessionListItem, SessionListResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' import { ModelPicker } from './modelPicker.js' import { windowOffset } from './overlayControls.js' import { TextInput } from './textInput.js' const VISIBLE = 12 const MIN_WIDTH = 64 const MAX_WIDTH = 128 const TITLE_MAX = 64 const STATUS_GLYPH: Record = { idle: '✓', starting: '…', waiting: '?', working: '▶' } const STATUS_LABEL: Record = { idle: 'idle', starting: 'starting', waiting: 'waiting', working: 'working' } const CTRL_OFFSET = 96 const shortModel = (model = '') => model.replace(/^.*\//, '') || 'model?' const ctrlChar = (letter: string) => String.fromCharCode(letter.charCodeAt(0) - CTRL_OFFSET) export const fixedSessionColumnStyle = () => ({ flexShrink: 0 }) export const activeSessionCountLabel = (count: number) => `${count} live ${count === 1 ? 'session' : 'sessions'}` export const sessionsCountLabel = (liveCount: number, resumableCount: number) => `${liveCount} live · ${resumableCount} resumable` export type SessionRowKind = 'history' | 'live' | 'new' /** * Map a flat row index into the merged Sessions list to its kind. Rows are * ordered [new][live…][history…] — the "+ new" row is pinned first so it is * always visible no matter how long the resumable history grows. */ export const sessionRowKindAt = (index: number, liveCount: number): SessionRowKind => { if (index <= 0) { return 'new' } return index - 1 < liveCount ? 'live' : 'history' } export const relativeSessionAge = (ts?: number) => { if (!ts) { return '' } const days = (Date.now() / 1000 - ts) / 86400 if (days < 1) { return 'today' } if (days < 2) { return 'yesterday' } return `${Math.floor(days)}d ago` } /** Drop already-live sessions from the resumable history list (dedupe by id). */ export const resumableHistory = (history: readonly SessionListItem[], live: readonly SessionActiveItem[]) => { const liveIds = new Set(live.map(s => s.id)) return history.filter(h => !liveIds.has(h.id)) } export const resumeRowContextHintSegments: OrchestratorHintSegment[] = [ { role: 'label', text: 'Resumable:' }, { role: 'text', text: ' ' }, { role: 'hotkey', text: 'Enter' }, { role: 'text', text: ' resume · ' }, { role: 'hotkey', text: 'd' }, { role: 'text', text: ' delete' } ] export type OrchestratorHintRole = 'hotkey' | 'label' | 'text' export interface OrchestratorHintSegment { role: OrchestratorHintRole text: string } export const orchestratorContextHintSegments = (newSelected: boolean): OrchestratorHintSegment[] => newSelected ? [ { 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' } ] : [ { 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' } ] export const orchestratorGlobalHotkeyHintSegments: OrchestratorHintSegment[] = [ { role: 'hotkey', text: '↑↓' }, { role: 'text', text: ' move · ' }, { role: 'hotkey', text: 'Ctrl+N' }, { role: 'text', text: ' new · ' }, { role: 'hotkey', text: 'Ctrl+R' }, { role: 'text', text: ' refresh · ' }, { role: 'hotkey', text: 'Esc' }, { role: 'text', text: ' close' } ] const hintText = (segments: readonly OrchestratorHintSegment[]) => segments.map(segment => segment.text).join('') export const orchestratorContextHint = (newSelected: boolean) => hintText(orchestratorContextHintSegments(newSelected)) export const orchestratorGlobalHotkeyHint = hintText(orchestratorGlobalHotkeyHintSegments) export const orchestratorHintSegmentColor = (t: Theme, role: OrchestratorHintRole) => { if (role === 'hotkey') { return t.color.accent } if (role === 'label') { return t.color.label } return t.color.muted } export const selectedSessionRowStyle = (t: Theme) => ({ backgroundColor: t.color.selectionBg, color: t.color.text }) export const newSessionMarkerColor = (t: Theme, selected: boolean) => selected ? selectedSessionRowStyle(t).color : t.color.label export const newSessionRowIndex = (sessionCount: number) => Math.max(0, sessionCount) export const isNewSessionRow = (index: number, sessionCount: number) => index >= newSessionRowIndex(sessionCount) export const canTypeOrchestratorPrompt = (index: number, sessionCount: number) => isNewSessionRow(index, sessionCount) export const clampOrchestratorSelection = (index: number, sessionCount: number) => Math.max(0, Math.min(index, newSessionRowIndex(sessionCount))) export const currentSessionSelectionIndex = ( sessions: readonly SessionActiveItem[], currentSessionId: null | string ) => { const index = sessions.findIndex(s => Boolean(s.current) || (!!currentSessionId && s.id === currentSessionId)) return index >= 0 ? index : 0 } export const orchestratorVisibleRowIndexes = (sessionCount: number, selected: number, visible = VISIBLE) => { const total = Math.max(0, sessionCount) + 1 const clamped = clampOrchestratorSelection(selected, sessionCount) const offset = windowOffset(total, clamped, visible) const count = Math.min(visible, total - offset) return Array.from({ length: count }, (_, i) => offset + i) } export type CloseFallback = { action: 'activate'; sessionId: string } | { action: 'new' } | { action: 'stay' } export const closeFallbackAfterClose = ( closedId: string, currentSessionId: null | string, remaining: readonly SessionActiveItem[] ): CloseFallback => { if (!currentSessionId || closedId !== currentSessionId) { return { action: 'stay' } } const next = remaining.find(s => s.id !== closedId) return next ? { action: 'activate', sessionId: next.id } : { action: 'new' } } export const draftModelArgFromPickerValue = (value: string) => { const parts = value.trim().split(/\s+/).filter(Boolean) const kept: string[] = [] for (const part of parts) { if (part === TUI_SESSION_MODEL_FLAG || part === '--global') { continue } kept.push(part) } return kept.join(' ') } export const draftModelNameFromArg = (value: string) => { const parts = draftModelArgFromPickerValue(value).split(/\s+/).filter(Boolean) const modelParts: string[] = [] for (let i = 0; i < parts.length; i++) { const part = parts[i]! if (part === '--provider') { i++ continue } if (part.startsWith('--')) { continue } modelParts.push(part) } return modelParts.join(' ').trim() } export const draftModelDisplayLabel = (value: string) => { const modelName = draftModelNameFromArg(value) return modelName ? shortModel(modelName) : 'current/default' } export type OrchestratorRowClickAction = { action: 'activate'; sessionId: string } | { action: 'select-new' } export const orchestratorRowClickAction = ( index: number, sessions: readonly SessionActiveItem[] ): OrchestratorRowClickAction => { const target = sessions[index] return target && !isNewSessionRow(index, sessions.length) ? { action: 'activate', sessionId: target.id } : { action: 'select-new' } } export const draftTitleFromPrompt = (prompt: string, max = TITLE_MAX) => { const compact = prompt.replace(/\s+/g, ' ').trim() if (compact.length <= max) { return compact } return `${compact.slice(0, Math.max(0, max - 1)).trimEnd()}…` } function OrchestratorHintSegments({ segments, t }: OrchestratorHintTextProps) { return ( <> {segments.map((segment, index) => ( {segment.text} ))} ) } function OrchestratorHintText({ segments, t }: OrchestratorHintTextProps) { return ( ) } export function ActiveSessionSwitcher({ currentSessionId, gw, onCancel, onClose, onNew, onNewPrompt, onResume, onSelect, t }: ActiveSessionSwitcherProps) { const [items, setItems] = useState([]) const [history, setHistory] = useState([]) const [err, setErr] = useState('') const [sel, setSel] = useState(0) const [loading, setLoading] = useState(true) const [draft, setDraft] = useState('') const [draftModel, setDraftModel] = useState('') const [pickingModel, setPickingModel] = useState(false) const [closingId, setClosingId] = useState('') // When non-null, the user pressed `d` on this (history) session and we await // a second `d` to confirm deletion. Tracked by session id (not row index) so // the 1.5s live-status poll re-indexing rows can't redirect the delete to a // different session. Any other key cancels the prompt. const [confirmDelete, setConfirmDelete] = useState(null) const [deleting, setDeleting] = useState(false) const initialSelectionAppliedRef = useRef(false) // Holds the RAW `session.list` results (pre-dedupe). The quiet 1.5s poll // re-derives the resumable list from this against the latest live set, so a // session that was hidden while live reappears in history once it closes — // without re-querying the DB. Only refreshed on a full (includeHistory) load. const rawHistoryRef = useRef([]) // Mirror the displayed lists so the async poll can re-anchor the selection to // the *same* row (by session id) after live sessions appear/disappear, rather // than keeping a now-stale flat index. const itemsRef = useRef([]) const historyDisplayRef = useRef([]) const { stdout } = useStdout() const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) const promptColumns = Math.max(20, width - 11) // Rows are [new][live…][history…]: the "+ new" row is pinned first (index 0, // always rendered) and the live+history list is windowed below it. `total` // is the count of selectable rows (incl. the new row). const liveCount = items.length const histCount = history.length const listLen = liveCount + histCount const total = listLen + 1 const rowKind = useCallback((index: number) => sessionRowKindAt(index, liveCount), [liveCount]) const load = useCallback( // `quiet` skips the loading spinner (used by the live-status poll); // `includeHistory` re-queries the resumable DB list (skipped on the 1.5s // poll, which only needs fresh live-session status). async (quiet = false, includeHistory = true) => { if (!quiet) { setLoading(true) } try { // Fetch independently (allSettled) so a failing session.list can't // wipe the live-session list: live sessions still render and the // resumable history degrades on its own. const [liveRes, histRes] = await Promise.allSettled([ gw.request('session.active_list', { current_session_id: currentSessionId }), includeHistory ? gw.request('session.list', { limit: 200 }) : Promise.resolve(null) ]) const r = liveRes.status === 'fulfilled' ? asRpcResult(liveRes.value) : null if (!r) { setErr('invalid response: session.active_list') setLoading(false) return [] } const next = r.sessions ?? [] // Surface a garbled/failed session.list rather than silently blanking // the resumable section; keep the last good raw history so a transient // failure doesn't wipe it. let histError = '' if (includeHistory) { if (histRes.status === 'fulfilled') { const parsedHist = asRpcResult(histRes.value) if (parsedHist) { rawHistoryRef.current = parsedHist.sessions ?? [] } else { histError = 'invalid response: session.list' } } else { histError = 'could not load resumable sessions' } } const hist = resumableHistory(rawHistoryRef.current, next) const initializeSelection = !initialSelectionAppliedRef.current initialSelectionAppliedRef.current = true const maxSel = next.length + hist.length // == total - 1 (new row is index 0) setItems(next) setHistory(hist) // Re-anchor selection to the same row by identity (the live list can // grow/shrink between polls, which would otherwise drift a flat index). setSel(s => { if (initializeSelection) { // Land on the current live session (shifted +1 past the pinned new // row); with no live sessions, start on the new row itself. return next.length ? Math.min(currentSessionSelectionIndex(next, currentSessionId) + 1, maxSel) : 0 } if (s <= 0) { return 0 // "+ new" row } const prevItems = itemsRef.current const prevHist = historyDisplayRef.current const clamp = () => Math.max(0, Math.min(s, maxSel)) if (s - 1 < prevItems.length) { const id = prevItems[s - 1]?.id const i = id ? next.findIndex(x => x.id === id) : -1 return i >= 0 ? i + 1 : clamp() } const id = prevHist[s - 1 - prevItems.length]?.id const i = id ? hist.findIndex(x => x.id === id) : -1 return i >= 0 ? 1 + next.length + i : clamp() }) setErr(histError) setLoading(false) return next } catch (e: unknown) { setErr(rpcErrorMessage(e)) setLoading(false) return [] } }, [currentSessionId, gw] ) useEffect(() => { itemsRef.current = items historyDisplayRef.current = history }, [items, history]) useEffect(() => { void load() const timer = setInterval(() => void load(true, false), 1500) return () => clearInterval(timer) }, [load]) const submitDraft = useCallback( (value: string) => { const prompt = value.trim() if (!prompt) { return } setDraft('') onNewPrompt(prompt, draftModel || undefined) }, [draftModel, onNewPrompt] ) const closeSelected = useCallback(async () => { const target = items[sel - 1] if (!target || rowKind(sel) !== 'live' || closingId) { return } setErr('') setClosingId(target.id) try { const result = await onClose(target.id) const closed = Boolean(result?.closed ?? result?.ok) if (!closed) { setErr('session was already closed') return } const remaining = await load(true) const fallback = closeFallbackAfterClose(target.id, currentSessionId, remaining) if (fallback.action === 'activate') { onSelect(fallback.sessionId) } else if (fallback.action === 'new') { onNew() } else { setSel(s => Math.max(0, Math.min(s, remaining.length + history.length))) } } catch (e: unknown) { setErr(rpcErrorMessage(e)) } finally { setClosingId('') } }, [closingId, currentSessionId, history.length, items, load, onClose, onNew, onSelect, rowKind, sel]) const performDelete = useCallback( (id: string) => { const target = history.find(h => h.id === id) if (!target || deleting) { return } setDeleting(true) gw.request('session.delete', { session_id: target.id }) .then(raw => { const r = asRpcResult(raw) if (!r || r.deleted !== target.id) { setErr('invalid response: session.delete') setDeleting(false) return } rawHistoryRef.current = rawHistoryRef.current.filter(h => h.id !== target.id) setHistory(prev => prev.filter(h => h.id !== target.id)) setSel(s => Math.max(0, Math.min(s, items.length + history.length - 1))) setErr('') setDeleting(false) }) .catch((e: unknown) => { setErr(rpcErrorMessage(e)) setDeleting(false) }) }, [deleting, gw, history, items.length] ) const handleRowClick = useCallback( (index: number) => (event: { stopImmediatePropagation?: () => void }) => { event.stopImmediatePropagation?.() const kind = rowKind(index) const clamped = Math.max(0, Math.min(index, total - 1)) if (kind === 'live') { setSel(clamped) onSelect(items[index - 1]!.id) return } if (kind === 'history') { setSel(clamped) onResume(history[index - 1 - items.length]!.id) return } setSel(0) }, [history, items, onResume, onSelect, rowKind, total] ) const selectedKind = rowKind(sel) const newSelected = selectedKind === 'new' const draftHasText = Boolean(draft.trim()) useInput((ch, key) => { if (pickingModel || deleting) { return } // Two-press history delete: once armed, only a second `d` deletes; any // other key cancels the prompt (mirrors the standalone resume picker). if (confirmDelete !== null) { if (ch?.toLowerCase() === 'd') { const id = confirmDelete setConfirmDelete(null) performDelete(id) } else { setConfirmDelete(null) } return } const lower = ch?.toLowerCase() ?? '' const isCtrl = (letter: string) => key.ctrl && (lower === letter || ch === ctrlChar(letter)) if (key.escape) { return onCancel() } if (isCtrl('n')) { return onNew() } if (isCtrl('r')) { void load() return } if (key.tab) { if (newSelected) { setPickingModel(true) } return } if (isCtrl('d')) { if (selectedKind === 'live') { void closeSelected() } return } // `d` arms deletion on a resumable history row. (On the New row `d` is // captured by the prompt's TextInput, so it never reaches here.) if (lower === 'd' && !key.ctrl && selectedKind === 'history') { setConfirmDelete(history[sel - 1 - items.length]?.id ?? null) return } if (newSelected && draftHasText) { return } if (key.upArrow && sel > 0) { return setSel(s => Math.max(0, s - 1)) } if (key.downArrow && sel < total - 1) { return setSel(s => Math.min(total - 1, s + 1)) } if (key.return) { if (newSelected) { if (!draftHasText) { return onNew() } return } if (selectedKind === 'live' && items[sel - 1]) { return onSelect(items[sel - 1]!.id) } if (selectedKind === 'history' && history[sel - 1 - items.length]) { return onResume(history[sel - 1 - items.length]!.id) } } }) if (pickingModel) { return ( setPickingModel(false)} onSelect={value => { setDraftModel(draftModelArgFromPickerValue(value)) setPickingModel(false) }} sessionId={currentSessionId} t={t} /> ) } if (loading) { return loading sessions… } // The "+ new" row (sel 0) is pinned at the top so it's always visible; the // live + history list is windowed beneath it. const listSel = sel > 0 ? sel - 1 : 0 const offset = windowOffset(listLen, listSel, VISIBLE) const visibleCount = Math.max(0, Math.min(VISIBLE, listLen - offset)) const visibleRows = Array.from({ length: visibleCount }, (_, k) => offset + k + 1) const newSelectedRow = sel === 0 const newRowStyle = newSelectedRow ? selectedSessionRowStyle(t) : null const newRowTextColor = newRowStyle?.color const newRowMarkerColor = newSessionMarkerColor(t, newSelectedRow) const promptTitle = draftTitleFromPrompt(draft) || 'Start a new live session' return ( Sessions {sessionsCountLabel(items.length, history.length)} {err && error: {err}} {newSelectedRow ? '▸ ' : ' '} {'+'.padStart(2)} new ✎ draft {draftModelDisplayLabel(draftModel)} {promptTitle} {offset > 0 && ↑ {offset} more} {!listLen && no other sessions — Enter on +new to start one} {visibleRows.map(i => { const selected = sel === i const selectedStyle = selected ? selectedSessionRowStyle(t) : null const rowTextColor = selectedStyle?.color const kind = rowKind(i) if (kind === 'history') { const h = history[i - 1 - items.length]! const pendingDelete = confirmDelete === h.id const title = pendingDelete ? 'press d again to delete' : deleting && selected ? 'deleting…' : h.title || h.preview || '(untitled)' return ( {selected ? '▸ ' : ' '} {String(i).padStart(2)}. {h.id} {relativeSessionAge(h.started_at)} {h.message_count} msgs {title} ) } const s = items[i - 1]! const status = s.status ?? 'idle' const current = s.current || s.id === currentSessionId const title = closingId === s.id ? 'closing…' : s.title || s.preview || '(untitled)' return ( {selected ? '▸ ' : ' '} {String(i).padStart(2)}. {current ? 'current' : s.id} {STATUS_GLYPH[status] ?? '·'} {STATUS_LABEL[status] ?? status} {shortModel(s.model)} {title} ) })} {offset + VISIBLE < listLen && ↓ {listLen - offset - VISIBLE} more} {newSelected ? ( <> prompt › model: {draftModelDisplayLabel(draftModel)} ) : ( Select +new to type a prompt )} ) } interface OrchestratorHintTextProps { segments: readonly OrchestratorHintSegment[] t: Theme } interface ActiveSessionSwitcherProps { currentSessionId: null | string gw: GatewayClient onCancel: () => void onClose: (id: string) => Promise onNew: () => void onNewPrompt: (prompt: string, modelArg?: string) => void onResume: (id: string) => void onSelect: (id: string) => void t: Theme }