From fabca0bdd82b1f69ae44212605f020aecdd1d3b9 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Mon, 1 Jun 2026 21:28:36 -0500 Subject: [PATCH] feat(tui): single /model command + unified Sessions overlay (#37112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tui): single /model command + unified Sessions overlay Collapse the redundant `/provider` alias so `/model` is the only name everywhere (it already drove the same 2-step ModelPicker in the TUI). Merge the separate `/resume` (cold history browser) and `/sessions` (live switcher) surfaces into one Sessions overlay reached by `/resume`, `/sessions`, `/session`, and `/switch`. It pins a "+ new" row at the top (always visible), lists live sessions with status, and lists resumable history below — dispatching session.activate for live rows vs resume for cold ones, with close/delete in place. Fixes `/session` opening an empty live-only switcher and the hidden new-session affordance. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(tui): address Copilot review on the Sessions overlay - Track the armed history-delete by session id instead of row index so the 1.5s live-status poll re-indexing rows can't redirect the second `d` to a different session. - Re-add the busy-session guard to immediate `/resume ` and `/sessions new` actions (browsing the bare overlay stays allowed) so resuming/switching can't corrupt an in-flight turn's streaming/busy state. * fix(tui): guard cold-resume (not live-switch/new) from the Sessions overlay Copilot flagged that overlay actions bypassed the busy guard. Only cold resume actually closes the current session, so only it is guarded — both from the slash path and now from the overlay (appActions.resumeById). Switching between live sessions and starting a `+ new` live session keep the current session running in the background, so they stay unguarded: that concurrency is the orchestrator's whole purpose. Also dropped the over-broad guard on `/sessions new` for the same reason. * fix(tui): address Copilot review (history dedup + desktop /provider) - The 1.5s poll now re-derives the resumable list from the RAW session.list results (rawHistoryRef) against the current live set, so a session hidden while live reappears in history once it closes — instead of being lost until a full reload. Delete also prunes the raw ref. - Drop the dead `/provider` entry from the desktop PICKER_OWNED_COMMANDS now that the alias is gone, so the desktop client no longer advertises it. * fix(tui): surface session.list errors + keep selection stable across polls - A garbled session.list response now surfaces an error and preserves the last good raw history, instead of silently blanking the resumable section. - The 1.5s poll re-anchors the selection to the same row by session id (live or history) when the live list grows/shrinks, so the highlight no longer drifts to a different row mid-interaction. * fix(tui): degrade session.list independently + cover overlay helpers - Fetch active_list and session.list via Promise.allSettled so a failing session.list no longer rejects the whole load: live sessions still render and only the resumable history degrades (with an error). - Add unit tests for the new helpers (sessionRowKindAt row ordering, resumableHistory dedupe, sessionsCountLabel, relativeSessionAge). * test(tui-gateway): assert /provider alias is gone, /model remains The CI test_complete_slash_includes_provider_alias asserted the removed `/provider` alias still autocompleted. Flip it to lock in the removal: `/pro` no longer offers `provider`, and `/mod` still completes `model`. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../desktop/src/lib/desktop-slash-commands.ts | 2 +- hermes_cli/commands.py | 2 +- tests/test_tui_gateway_server.py | 13 +- .../__tests__/activeSessionSwitcher.test.ts | 71 +++- .../src/__tests__/createSlashHandler.test.ts | 30 +- ui-tui/src/app/interfaces.ts | 3 +- ui-tui/src/app/overlayStore.ts | 8 +- ui-tui/src/app/slash/commands/core.ts | 12 - ui-tui/src/app/slash/commands/session.ts | 22 +- ui-tui/src/app/useInputHandlers.ts | 8 +- ui-tui/src/app/useMainApp.ts | 13 +- ui-tui/src/app/useSessionLifecycle.ts | 2 +- .../src/components/activeSessionSwitcher.tsx | 394 +++++++++++++++--- ui-tui/src/components/appLayout.tsx | 4 +- ui-tui/src/components/appOverlays.tsx | 18 +- ui-tui/src/components/helpHint.tsx | 2 +- ui-tui/src/components/sessionPicker.tsx | 227 ---------- 17 files changed, 482 insertions(+), 349 deletions(-) delete mode 100644 ui-tui/src/components/sessionPicker.tsx diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index db3a4ec3e..bcf8be585 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -59,7 +59,7 @@ const DESKTOP_ALIASES = new Map([ const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap = new Map(DESKTOP_COMMAND_META) -const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider']) +const PICKER_OWNED_COMMANDS = new Set(['/model']) const TERMINAL_ONLY_COMMANDS = new Set([ '/browser', diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 4979a32cb..34e3af401 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -124,7 +124,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("config", "Show current configuration", "Configuration", cli_only=True), CommandDef("model", "Switch model for this session", "Configuration", - aliases=("provider",), args_hint="[model] [--provider name] [--global] [--refresh]"), + args_hint="[model] [--provider name] [--global] [--refresh]"), CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models", "Configuration", aliases=("codex_runtime",), args_hint="[auto|codex_app_server]"), diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 7d2172258..4fc017729 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1733,12 +1733,21 @@ def test_setup_runtime_check_rejects_implicit_bedrock_when_unconfigured(monkeypa assert resp["result"]["provider"] == "bedrock" -def test_complete_slash_includes_provider_alias(): +def test_complete_slash_drops_removed_provider_alias(): + # `/provider` was folded into a single `/model` command, so autocomplete + # must no longer offer the dead alias... resp = server.handle_request( {"id": "1", "method": "complete.slash", "params": {"text": "/pro"}} ) - assert any(item["text"] == "provider" for item in resp["result"]["items"]) + assert not any(item["text"] == "provider" for item in resp["result"]["items"]) + + # ...while `/model` stays the canonical command. + resp_model = server.handle_request( + {"id": "2", "method": "complete.slash", "params": {"text": "/mod"}} + ) + + assert any(item["text"] == "model" for item in resp_model["result"]["items"]) def test_complete_slash_returns_plain_string_fields(): diff --git a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts index 3e69449dc..53426b0e2 100644 --- a/ui-tui/src/__tests__/activeSessionSwitcher.test.ts +++ b/ui-tui/src/__tests__/activeSessionSwitcher.test.ts @@ -1,29 +1,34 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_THEME } from '../theme.js' -import type { SessionActiveItem } from '../gatewayTypes.js' import { activeSessionCountLabel, canTypeOrchestratorPrompt, + clampOrchestratorSelection, + closeFallbackAfterClose, currentSessionSelectionIndex, + draftModelArgFromPickerValue, + draftModelDisplayLabel, + draftTitleFromPrompt, + fixedSessionColumnStyle, + isNewSessionRow, + newSessionMarkerColor, + newSessionRowIndex, orchestratorContextHint, orchestratorContextHintSegments, orchestratorGlobalHotkeyHint, orchestratorGlobalHotkeyHintSegments, orchestratorHintSegmentColor, - clampOrchestratorSelection, - closeFallbackAfterClose, - draftModelArgFromPickerValue, - draftModelDisplayLabel, - fixedSessionColumnStyle, - draftTitleFromPrompt, - isNewSessionRow, - newSessionMarkerColor, - newSessionRowIndex, orchestratorRowClickAction, orchestratorVisibleRowIndexes, - selectedSessionRowStyle + relativeSessionAge, + resumableHistory, + selectedSessionRowStyle, + sessionRowKindAt, + sessionsCountLabel } from '../components/activeSessionSwitcher.js' +import type { SessionActiveItem } from '../gatewayTypes.js' +import type { SessionListItem } from '../gatewayTypes.js' +import { DEFAULT_THEME } from '../theme.js' describe('session orchestrator helpers', () => { it('labels live sessions compactly for tight overlays', () => { @@ -155,3 +160,45 @@ describe('session orchestrator helpers', () => { ) }) }) + +describe('unified Sessions overlay helpers', () => { + it('orders rows as [new][live…][history…]', () => { + // 2 live sessions, any number of history rows after them. + expect(sessionRowKindAt(0, 2)).toBe('new') + expect(sessionRowKindAt(1, 2)).toBe('live') + expect(sessionRowKindAt(2, 2)).toBe('live') + expect(sessionRowKindAt(3, 2)).toBe('history') + expect(sessionRowKindAt(9, 2)).toBe('history') + // No live sessions: row 0 is new, everything after is history. + expect(sessionRowKindAt(0, 0)).toBe('new') + expect(sessionRowKindAt(1, 0)).toBe('history') + }) + + it('drops already-live sessions from the resumable history (dedupe by id)', () => { + const history = [ + { id: 'a', message_count: 1, preview: '', started_at: 0, title: 'A' }, + { id: 'b', message_count: 2, preview: '', started_at: 0, title: 'B' }, + { id: 'c', message_count: 3, preview: '', started_at: 0, title: 'C' } + ] satisfies SessionListItem[] + + const live = [{ id: 'b', status: 'idle' }] satisfies SessionActiveItem[] + + expect(resumableHistory(history, live).map(h => h.id)).toEqual(['a', 'c']) + expect(resumableHistory(history, []).map(h => h.id)).toEqual(['a', 'b', 'c']) + }) + + it('labels live + resumable counts compactly', () => { + expect(sessionsCountLabel(0, 0)).toBe('0 live · 0 resumable') + expect(sessionsCountLabel(2, 7)).toBe('2 live · 7 resumable') + }) + + it('renders relative session age, blank when unknown', () => { + const nowSec = Math.floor(Date.now() / 1000) + + expect(relativeSessionAge(nowSec)).toBe('today') + expect(relativeSessionAge(nowSec - 36 * 3600)).toBe('yesterday') + expect(relativeSessionAge(nowSec - 3 * 86400)).toBe('3d ago') + expect(relativeSessionAge(undefined)).toBe('') + expect(relativeSessionAge(0)).toBe('') + }) +}) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 8e6348e5d..9a68c5b2f 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -11,14 +11,22 @@ describe('createSlashHandler', () => { resetUiState() }) - it('opens the resume picker locally', () => { + it('opens the unified sessions overlay for /resume', () => { const ctx = buildCtx() expect(createSlashHandler(ctx)('/resume')).toBe(true) - expect(getOverlayState().picker).toBe(true) + expect(getOverlayState().sessions).toBe(true) }) - it('opens the live session switcher locally even when the current session is busy', () => { + it('resumes a prior session by id when /resume has an argument', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/resume sid-old')).toBe(true) + expect(ctx.session.resumeById).toHaveBeenCalledWith('sid-old') + expect(getOverlayState().sessions).toBe(false) + }) + + it('opens the unified sessions overlay locally even when the current session is busy', () => { patchUiState({ busy: true, sid: 'sid-abc' }) const ctx = buildCtx() @@ -28,6 +36,22 @@ describe('createSlashHandler', () => { expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) + it('blocks immediate resume-by-id while a turn is busy', () => { + patchUiState({ busy: true, sid: 'sid-abc' }) + const ctx = buildCtx({ session: { ...buildSession(), guardBusySessionSwitch: vi.fn(() => true) } }) + + expect(createSlashHandler(ctx)('/resume sid-old')).toBe(true) + expect(ctx.session.guardBusySessionSwitch).toHaveBeenCalled() + expect(ctx.session.resumeById).not.toHaveBeenCalled() + }) + + it('treats /session (singular) as an alias of the sessions overlay', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/session')).toBe(true) + expect(getOverlayState().sessions).toBe(true) + }) + it('handles /redraw locally without slash worker fallback', () => { const ctx = buildCtx() diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index cbedac59c..bcb1578e4 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -77,7 +77,6 @@ export interface OverlayState { confirm: ConfirmReq | null modelPicker: boolean pager: null | PagerState - picker: boolean secret: null | SecretReq sessions: boolean skillsHub: boolean @@ -385,7 +384,7 @@ export interface AppOverlaysProps { onModelSelect: (value: string) => void onNewLiveSession: () => void onNewPromptSession: (prompt: string, modelArg?: string) => void - onPickerSelect: (sessionId: string) => void + onResumeSelect: (sessionId: string) => void onSecretSubmit: (value: string) => void onSudoSubmit: (pw: string) => void pagerPageSize: number diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index 72b7021f0..52692c6b3 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -10,7 +10,6 @@ const buildOverlayState = (): OverlayState => ({ confirm: null, modelPicker: false, pager: null, - picker: false, secret: null, sessions: false, skillsHub: false, @@ -21,8 +20,8 @@ export const $overlayState = atom(buildOverlayState()) export const $isBlocked = computed( $overlayState, - ({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, sessions, skillsHub, sudo }) => - Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || sessions || skillsHub || sudo) + ({ agents, approval, clarify, confirm, modelPicker, pager, secret, sessions, skillsHub, sudo }) => + Boolean(agents || approval || clarify || confirm || modelPicker || pager || secret || sessions || skillsHub || sudo) ) export const getOverlayState = () => $overlayState.get() @@ -36,7 +35,7 @@ export const resetOverlayState = () => $overlayState.set(buildOverlayState()) /** * Soft reset: drop FLOW-scoped overlays (approval / clarify / confirm / sudo * / secret / pager) but PRESERVE user-toggled ones — agents dashboard, model - * picker, skills hub, session picker. Those are opened deliberately and + * picker, skills hub, sessions overlay. Those are opened deliberately and * shouldn't vanish when a turn ends. Called from turnController.idle() on * every turn completion / interrupt; the old "reset everything" behaviour * silently closed /agents the moment delegation finished. @@ -47,7 +46,6 @@ export const resetFlowOverlays = () => agents: $overlayState.get().agents, agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex, modelPicker: $overlayState.get().modelPicker, - picker: $overlayState.get().picker, sessions: $overlayState.get().sessions, skillsHub: $overlayState.get().skillsHub }) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index d3880c25c..5c021dbcd 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -204,18 +204,6 @@ export const coreCommands: SlashCommand[] = [ } }, - { - 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: 'set or show current session title', name: 'title', diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index e2fe6f852..bcc38d3f1 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -93,14 +93,30 @@ export const sessionCommands: SlashCommand[] = [ }, { - aliases: ['switch'], - help: 'switch between live TUI sessions', + aliases: ['switch', 'session', 'resume'], + help: 'browse, switch, or resume sessions', name: 'sessions', run: (arg, ctx) => { - if (arg.trim().toLowerCase() === 'new') { + const trimmed = arg.trim() + + // A new *live* session keeps the current one running in the background + // (it doesn't close it), so fanning out while busy is allowed — that's + // the whole point of multiple live sessions. + if (trimmed.toLowerCase() === 'new') { return ctx.session.newLiveSession() } + // `/resume ` (and `/sessions `) load a cold session and + // CLOSE the current one, so guard it while a turn is in-flight to avoid + // corrupting streaming/busy state. Bare opens the overlay to browse. + if (trimmed) { + if (ctx.session.guardBusySessionSwitch('switch sessions')) { + return + } + + return ctx.session.resumeById(trimmed) + } + patchOverlayState({ sessions: true }) } }, diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 2cbb745b8..f18ebc580 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -151,8 +151,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return patchOverlayState({ skillsHub: false }) } - if (overlay.picker) { - return patchOverlayState({ picker: false }) + if (overlay.sessions) { + return patchOverlayState({ sessions: false }) } if (overlay.agents) { @@ -341,8 +341,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (isCtrl(key, ch, 'c')) { cancelOverlayFromCtrlC() - } else if (key.escape && overlay.picker) { - patchOverlayState({ picker: false }) + } else if (key.escape && overlay.sessions) { + patchOverlayState({ sessions: false }) } // When a prompt overlay is up and the user pressed a scroll key, fall diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 6915c4c5f..bb703e134 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -988,7 +988,17 @@ export function useMainApp(gw: GatewayClient) { newLiveSession: () => session.newLiveSession(), newPromptSession, onModelSelect, - resumeById: session.resumeById, + // Resuming a cold session from the overlay CLOSES the current one, so it + // must respect the busy guard just like the `/resume` slash path. + // (Switching between live sessions and `+ new` keep the current session + // running, so those stay unguarded — that's the orchestrator's purpose.) + resumeById: (id: string) => { + if (session.guardBusySessionSwitch('switch sessions')) { + return + } + + session.resumeById(id) + }, setStickyPrompt }), [ @@ -1001,6 +1011,7 @@ export function useMainApp(gw: GatewayClient) { newPromptSession, onModelSelect, session.activateLiveSession, + session.guardBusySessionSwitch, session.newLiveSession, session.resumeById ] diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 5857b44dd..c2c1ddac8 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -291,7 +291,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { const resumeById = useCallback( (id: string) => { - patchOverlayState({ picker: false }) + patchOverlayState({ sessions: false }) patchUiState({ status: 'resuming…' }) rpc('setup.status', {}).then(setup => { diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index f158b24a4..68fa44e3a 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -3,7 +3,14 @@ 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 } from '../gatewayTypes.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' @@ -40,6 +47,58 @@ 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 { @@ -239,10 +298,12 @@ export function ActiveSessionSwitcher({ 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) @@ -250,22 +311,56 @@ export function ActiveSessionSwitcher({ 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( - async (quiet = false) => { + // `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 { - const raw = await gw.request('session.active_list', { - current_session_id: currentSessionId - }) - const r = asRpcResult(raw) + // 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') @@ -275,15 +370,63 @@ export function ActiveSessionSwitcher({ } 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) - setSel(s => - initializeSelection - ? clampOrchestratorSelection(currentSessionSelectionIndex(next, currentSessionId), next.length) - : clampOrchestratorSelection(s, next.length) - ) - setErr('') + 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 @@ -297,9 +440,14 @@ export function ActiveSessionSwitcher({ [currentSessionId, gw] ) + useEffect(() => { + itemsRef.current = items + historyDisplayRef.current = history + }, [items, history]) + useEffect(() => { void load() - const timer = setInterval(() => void load(true), 1500) + const timer = setInterval(() => void load(true, false), 1500) return () => clearInterval(timer) }, [load]) @@ -319,9 +467,9 @@ export function ActiveSessionSwitcher({ ) const closeSelected = useCallback(async () => { - const target = items[sel] + const target = items[sel - 1] - if (!target || isNewSessionRow(sel, items.length) || closingId) { + if (!target || rowKind(sel) !== 'live' || closingId) { return } @@ -346,37 +494,94 @@ export function ActiveSessionSwitcher({ } else if (fallback.action === 'new') { onNew() } else { - setSel(s => clampOrchestratorSelection(s, remaining.length)) + setSel(s => Math.max(0, Math.min(s, remaining.length + history.length))) } } catch (e: unknown) { setErr(rpcErrorMessage(e)) } finally { setClosingId('') } - }, [closingId, currentSessionId, items, load, onClose, onNew, onSelect, sel]) + }, [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 action = orchestratorRowClickAction(index, items) + const kind = rowKind(index) + const clamped = Math.max(0, Math.min(index, total - 1)) - if (action.action === 'activate') { - setSel(clampOrchestratorSelection(index, items.length)) - onSelect(action.sessionId) + if (kind === 'live') { + setSel(clamped) + onSelect(items[index - 1]!.id) return } - setSel(newSessionRowIndex(items.length)) + if (kind === 'history') { + setSel(clamped) + onResume(history[index - 1 - items.length]!.id) + + return + } + + setSel(0) }, - [items, onSelect] + [history, items, onResume, onSelect, rowKind, total] ) - const newSelected = isNewSessionRow(sel, items.length) + const selectedKind = rowKind(sel) + const newSelected = selectedKind === 'new' const draftHasText = Boolean(draft.trim()) useInput((ch, key) => { - if (pickingModel) { + 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 } @@ -406,23 +611,31 @@ export function ActiveSessionSwitcher({ } if (isCtrl('d')) { - if (!newSelected) { + 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 => clampOrchestratorSelection(s - 1, items.length)) + return setSel(s => Math.max(0, s - 1)) } - if (key.downArrow && sel < newSessionRowIndex(items.length)) { - return setSel(s => clampOrchestratorSelection(s + 1, items.length)) + if (key.downArrow && sel < total - 1) { + return setSel(s => Math.min(total - 1, s + 1)) } if (key.return) { @@ -434,8 +647,12 @@ export function ActiveSessionSwitcher({ return } - if (items[sel]) { - return onSelect(items[sel]!.id) + 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) } } }) @@ -457,40 +674,95 @@ export function ActiveSessionSwitcher({ } if (loading) { - return loading session orchestrator… + return loading sessions… } - const totalRows = items.length + 1 - const offset = windowOffset(totalRows, sel, VISIBLE) - const visibleRows = orchestratorVisibleRowIndexes(items.length, sel, VISIBLE) + // 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 ( - Session Orchestrator + Sessions - {activeSessionCountLabel(items.length)} + {sessionsCountLabel(items.length, history.length)} {err && error: {err}} - {!items.length && ( - no live sessions — closed TUIs only leave resumable transcripts - )} + + + + {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 (isNewSessionRow(i, items.length)) { - const promptTitle = draftTitleFromPrompt(draft) || 'Start a new live session' - const markerColor = newSessionMarkerColor(t, selected) + 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 ( @@ -499,39 +771,43 @@ export function ActiveSessionSwitcher({ - - + + + {String(i).padStart(2)}. - - new + + {h.id} - ✎ draft + {relativeSessionAge(h.started_at)} - {draftModelDisplayLabel(draftModel)} + {h.message_count} msgs - - {promptTitle} + + {title} ) } - const s = items[i]! + 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)' @@ -550,7 +826,7 @@ export function ActiveSessionSwitcher({ - {String(i + 1).padStart(2)}. + {String(i).padStart(2)}. @@ -591,7 +867,7 @@ export function ActiveSessionSwitcher({ ) })} - {offset + VISIBLE < totalRows && ↓ {totalRows - offset - VISIBLE} more} + {offset + VISIBLE < listLen && ↓ {listLen - offset - VISIBLE} more} {newSelected ? ( <> @@ -605,8 +881,11 @@ export function ActiveSessionSwitcher({ ) : ( - - + + Select +new to type a prompt @@ -630,6 +909,7 @@ interface ActiveSessionSwitcherProps { onClose: (id: string) => Promise onNew: () => void onNewPrompt: (prompt: string, modelArg?: string) => void + onResume: (id: string) => void onSelect: (id: string) => void t: Theme } diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index b036465f3..f4204551c 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -252,12 +252,12 @@ const ComposerPane = memo(function ComposerPane({ cols={composer.cols} compIdx={composer.compIdx} completions={composer.completions} - onActiveSessionSelect={actions.activateLiveSession} onActiveSessionClose={actions.closeLiveSession} + onActiveSessionSelect={actions.activateLiveSession} onModelSelect={actions.onModelSelect} onNewLiveSession={actions.newLiveSession} onNewPromptSession={actions.newPromptSession} - onPickerSelect={actions.resumeById} + onResumeSelect={actions.resumeById} pagerPageSize={composer.pagerPageSize} /> diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 600a2ac19..fd7f8aaa8 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -12,7 +12,6 @@ import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' import { OverlayHint } from './overlayControls.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' -import { SessionPicker } from './sessionPicker.js' import { SkillsHub } from './skillsHub.js' const COMPLETION_WINDOW = 16 @@ -101,7 +100,7 @@ export function FloatingOverlays({ onModelSelect, onNewLiveSession, onNewPromptSession, - onPickerSelect, + onResumeSelect, pagerPageSize }: Pick< AppOverlaysProps, @@ -113,7 +112,7 @@ export function FloatingOverlays({ | 'onModelSelect' | 'onNewLiveSession' | 'onNewPromptSession' - | 'onPickerSelect' + | 'onResumeSelect' | 'pagerPageSize' >) { const { gw } = useGateway() @@ -124,7 +123,6 @@ export function FloatingOverlays({ const hasAny = overlay.modelPicker || overlay.pager || - overlay.picker || overlay.sessions || overlay.skillsHub || completions.length @@ -142,17 +140,6 @@ export function FloatingOverlays({ return ( - {overlay.picker && ( - - patchOverlayState({ picker: false })} - onSelect={onPickerSelect} - t={theme} - /> - - )} - {overlay.sessions && ( diff --git a/ui-tui/src/components/helpHint.tsx b/ui-tui/src/components/helpHint.tsx index 5634ef566..89049ce14 100644 --- a/ui-tui/src/components/helpHint.tsx +++ b/ui-tui/src/components/helpHint.tsx @@ -6,7 +6,7 @@ import type { Theme } from '../theme.js' const COMMON_COMMANDS: [string, string][] = [ ['/help', 'full list of commands + hotkeys'], ['/clear', 'start a new session'], - ['/resume', 'resume a prior session'], + ['/resume', 'switch live or resume past sessions'], ['/details', 'control transcript detail level'], ['/copy', 'copy selection or last assistant message'], ['/quit', 'exit hermes'] diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx deleted file mode 100644 index e836e5985..000000000 --- a/ui-tui/src/components/sessionPicker.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { Box, Text, useInput, useStdout } from '@hermes/ink' -import { useEffect, useState } from 'react' - -import type { GatewayClient } from '../gatewayClient.js' -import type { SessionDeleteResponse, SessionListItem, SessionListResponse } from '../gatewayTypes.js' -import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' -import type { Theme } from '../theme.js' - -import { OverlayHint, useOverlayKeys, windowOffset } from './overlayControls.js' - -const VISIBLE = 15 -const MIN_WIDTH = 60 -const MAX_WIDTH = 120 - -const age = (ts: number) => { - const d = (Date.now() / 1000 - ts) / 86400 - - if (d < 1) { - return 'today' - } - - if (d < 2) { - return 'yesterday' - } - - return `${Math.floor(d)}d ago` -} - -export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) { - const [items, setItems] = useState([]) - const [err, setErr] = useState('') - const [sel, setSel] = useState(0) - const [loading, setLoading] = useState(true) - // When non-null, the user pressed `d` on this index and we're waiting for - // a second `d`/`D` to confirm deletion. Any other key cancels the prompt. - const [confirmDelete, setConfirmDelete] = useState(null) - const [deleting, setDeleting] = useState(false) - - const { stdout } = useStdout() - const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) - - useOverlayKeys({ onClose: onCancel }) - - useEffect(() => { - gw.request('session.list', { limit: 200 }) - .then(raw => { - const r = asRpcResult(raw) - - if (!r) { - setErr('invalid response: session.list') - setLoading(false) - - return - } - - setItems(r.sessions ?? []) - setErr('') - setLoading(false) - }) - .catch((e: unknown) => { - setErr(rpcErrorMessage(e)) - setLoading(false) - }) - }, [gw]) - - const performDelete = (index: number) => { - const target = items[index] - - 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 - } - - setItems(prev => { - const next = prev.filter((_, i) => i !== index) - setSel(s => Math.max(0, Math.min(s, next.length - 1))) - - return next - }) - setErr('') - setDeleting(false) - }) - .catch((e: unknown) => { - setErr(rpcErrorMessage(e)) - setDeleting(false) - }) - } - - useInput((ch, key) => { - if (deleting) { - return - } - - if (confirmDelete !== null) { - if (ch?.toLowerCase() === 'd') { - const idx = confirmDelete - setConfirmDelete(null) - performDelete(idx) - } else { - setConfirmDelete(null) - } - - return - } - - if (key.upArrow && sel > 0) { - setSel(s => s - 1) - } - - if (key.downArrow && sel < items.length - 1) { - setSel(s => s + 1) - } - - if (key.return && items[sel]) { - onSelect(items[sel]!.id) - - return - } - - if (ch?.toLowerCase() === 'd' && items[sel]) { - setConfirmDelete(sel) - - return - } - - const n = parseInt(ch) - - if (n >= 1 && n <= Math.min(9, items.length)) { - onSelect(items[n - 1]!.id) - } - }) - - if (loading) { - return loading sessions… - } - - if (err && !items.length) { - return ( - - error: {err} - Esc/q cancel - - ) - } - - if (!items.length) { - return ( - - no previous sessions - Esc/q cancel - - ) - } - - const offset = windowOffset(items.length, sel, VISIBLE) - - return ( - - - Resume Session - - - {offset > 0 && ↑ {offset} more} - - {items.slice(offset, offset + VISIBLE).map((s, vi) => { - const i = offset + vi - const selected = sel === i - const pendingDelete = confirmDelete === i - - return ( - - - {selected ? '▸ ' : ' '} - - - - - {String(i + 1).padStart(2)}. [{s.id}] - - - - - - ({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'}) - - - - - {pendingDelete ? 'press d again to delete' : s.title || s.preview || '(untitled)'} - - - ) - })} - - {offset + VISIBLE < items.length && ↓ {items.length - offset - VISIBLE} more} - {err && error: {err}} - {deleting ? ( - deleting… - ) : ( - ↑/↓ select · Enter resume · 1-9 quick · d delete · Esc/q cancel - )} - - ) -} - -interface SessionPickerProps { - gw: GatewayClient - onCancel: () => void - onSelect: (id: string) => void - t: Theme -}