feat(tui): single /model command + unified Sessions overlay (#37112)

* 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 <id>` 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>
This commit is contained in:
brooklyn!
2026-06-01 21:28:36 -05:00
committed by GitHub
parent f7a3509b25
commit fabca0bdd8
17 changed files with 482 additions and 349 deletions

View File

@ -59,7 +59,7 @@ const DESKTOP_ALIASES = new Map([
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = 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',

View File

@ -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]"),

View File

@ -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():

View File

@ -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('')
})
})

View File

@ -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()

View File

@ -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

View File

@ -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<OverlayState>(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
})

View File

@ -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',

View File

@ -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 <id|title>` (and `/sessions <id>`) 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 })
}
},

View File

@ -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

View File

@ -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
]

View File

@ -291,7 +291,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
const resumeById = useCallback(
(id: string) => {
patchOverlayState({ picker: false })
patchOverlayState({ sessions: false })
patchUiState({ status: 'resuming…' })
rpc<SetupStatusResponse>('setup.status', {}).then(setup => {

View File

@ -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<SessionActiveItem[]>([])
const [history, setHistory] = useState<SessionListItem[]>([])
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 | string>(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<SessionListItem[]>([])
// 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<SessionActiveItem[]>([])
const historyDisplayRef = useRef<SessionListItem[]>([])
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<SessionActiveListResponse>('session.active_list', {
current_session_id: currentSessionId
})
const r = asRpcResult<SessionActiveListResponse>(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<SessionActiveListResponse>('session.active_list', {
current_session_id: currentSessionId
}),
includeHistory ? gw.request<SessionListResponse>('session.list', { limit: 200 }) : Promise.resolve(null)
])
const r = liveRes.status === 'fulfilled' ? asRpcResult<SessionActiveListResponse>(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<SessionListResponse>(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<SessionDeleteResponse>('session.delete', { session_id: target.id })
.then(raw => {
const r = asRpcResult<SessionDeleteResponse>(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 <Text color={t.color.muted}>loading session orchestrator</Text>
return <Text color={t.color.muted}>loading sessions</Text>
}
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 (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Session Orchestrator
Sessions
</Text>
<Text color={t.color.muted}>{activeSessionCountLabel(items.length)}</Text>
<Text color={t.color.muted}>{sessionsCountLabel(items.length, history.length)}</Text>
{err && <Text color={t.color.label}>error: {err}</Text>}
{!items.length && (
<Text color={t.color.muted}>no live sessions closed TUIs only leave resumable transcripts</Text>
)}
<Box
backgroundColor={newRowStyle?.backgroundColor}
flexDirection="row"
onClick={handleRowClick(0)}
width="100%"
>
<Text bold={newSelectedRow} color={newRowTextColor ?? t.color.muted}>
{newSelectedRow ? '▸ ' : ' '}
</Text>
<Box {...fixedSessionColumnStyle()} width={5}>
<Text bold={newSelectedRow} color={newRowMarkerColor}>
{'+'.padStart(2)}
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text bold={newSelectedRow} color={newRowMarkerColor} wrap="truncate-end">
new
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text color={newRowTextColor ?? t.color.muted} wrap="truncate-end">
draft
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={18}>
<Text color={newRowTextColor ?? t.color.muted} wrap="truncate-end">
{draftModelDisplayLabel(draftModel)}
</Text>
</Box>
<Box flexGrow={1} flexShrink={1} minWidth={0}>
<Text bold={newSelectedRow} color={newRowTextColor ?? t.color.muted} wrap="truncate-end">
{promptTitle}
</Text>
</Box>
</Box>
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{!listLen && <Text color={t.color.muted}>no other sessions Enter on +new to start one</Text>}
{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 (
<Box
backgroundColor={selectedStyle?.backgroundColor}
flexDirection="row"
key="new-session"
key={h.id}
onClick={handleRowClick(i)}
width="100%"
>
@ -499,39 +771,43 @@ export function ActiveSessionSwitcher({
</Text>
<Box {...fixedSessionColumnStyle()} width={5}>
<Text bold={selected} color={markerColor}>
+
<Text bold={selected} color={rowTextColor ?? t.color.muted}>
{String(i).padStart(2)}.
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text bold={selected} color={markerColor} wrap="truncate-end">
new
<Text bold={selected} color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{h.id}
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={11}>
<Text color={rowTextColor ?? t.color.muted} wrap="truncate-end">
draft
{relativeSessionAge(h.started_at)}
</Text>
</Box>
<Box {...fixedSessionColumnStyle()} width={18}>
<Text color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{draftModelDisplayLabel(draftModel)}
{h.message_count} msgs
</Text>
</Box>
<Box flexGrow={1} flexShrink={1} minWidth={0}>
<Text bold={selected} color={rowTextColor ?? t.color.muted} wrap="truncate-end">
{promptTitle}
<Text
bold={selected}
color={pendingDelete ? t.color.label : rowTextColor ?? t.color.muted}
wrap="truncate-end"
>
{title}
</Text>
</Box>
</Box>
)
}
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({
<Box {...fixedSessionColumnStyle()} width={5}>
<Text bold={selected} color={rowTextColor ?? t.color.muted}>
{String(i + 1).padStart(2)}.
{String(i).padStart(2)}.
</Text>
</Box>
@ -591,7 +867,7 @@ export function ActiveSessionSwitcher({
)
})}
{offset + VISIBLE < totalRows && <Text color={t.color.muted}> {totalRows - offset - VISIBLE} more</Text>}
{offset + VISIBLE < listLen && <Text color={t.color.muted}> {listLen - offset - VISIBLE} more</Text>}
{newSelected ? (
<>
@ -605,8 +881,11 @@ export function ActiveSessionSwitcher({
</Text>
</>
) : (
<Box marginTop={1} flexDirection="column">
<OrchestratorHintText segments={orchestratorContextHintSegments(false)} t={t} />
<Box flexDirection="column" marginTop={1}>
<OrchestratorHintText
segments={selectedKind === 'history' ? resumeRowContextHintSegments : orchestratorContextHintSegments(false)}
t={t}
/>
<Text color={t.color.muted} wrap="truncate-end">
Select <Text color={newSessionMarkerColor(t, false)}>+new</Text> to type a prompt
</Text>
@ -630,6 +909,7 @@ interface ActiveSessionSwitcherProps {
onClose: (id: string) => Promise<null | SessionCloseResponse>
onNew: () => void
onNewPrompt: (prompt: string, modelArg?: string) => void
onResume: (id: string) => void
onSelect: (id: string) => void
t: Theme
}

View File

@ -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}
/>

View File

@ -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 (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.picker && (
<FloatBox color={theme.color.border}>
<SessionPicker
gw={gw}
onCancel={() => patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
t={theme}
/>
</FloatBox>
)}
{overlay.sessions && (
<FloatBox color={theme.color.border}>
<ActiveSessionSwitcher
@ -162,6 +149,7 @@ export function FloatingOverlays({
onClose={onActiveSessionClose}
onNew={onNewLiveSession}
onNewPrompt={onNewPromptSession}
onResume={onResumeSelect}
onSelect={onActiveSessionSelect}
t={theme}
/>

View File

@ -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']

View File

@ -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<SessionListItem[]>([])
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 | number>(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<SessionListResponse>('session.list', { limit: 200 })
.then(raw => {
const r = asRpcResult<SessionListResponse>(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<SessionDeleteResponse>('session.delete', { session_id: target.id })
.then(raw => {
const r = asRpcResult<SessionDeleteResponse>(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 <Text color={t.color.muted}>loading sessions</Text>
}
if (err && !items.length) {
return (
<Box flexDirection="column">
<Text color={t.color.label}>error: {err}</Text>
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box>
)
}
if (!items.length) {
return (
<Box flexDirection="column">
<Text color={t.color.muted}>no previous sessions</Text>
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box>
)
}
const offset = windowOffset(items.length, sel, VISIBLE)
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Resume Session
</Text>
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{items.slice(offset, offset + VISIBLE).map((s, vi) => {
const i = offset + vi
const selected = sel === i
const pendingDelete = confirmDelete === i
return (
<Box key={s.id}>
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
{selected ? '▸ ' : ' '}
</Text>
<Box width={30}>
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
{String(i + 1).padStart(2)}. [{s.id}]
</Text>
</Box>
<Box width={30}>
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
</Text>
</Box>
<Text
bold={selected}
color={pendingDelete ? t.color.label : selected ? t.color.accent : t.color.muted}
inverse={selected}
wrap="truncate-end"
>
{pendingDelete ? 'press d again to delete' : s.title || s.preview || '(untitled)'}
</Text>
</Box>
)
})}
{offset + VISIBLE < items.length && <Text color={t.color.muted}> {items.length - offset - VISIBLE} more</Text>}
{err && <Text color={t.color.label}>error: {err}</Text>}
{deleting ? (
<OverlayHint t={t}>deleting</OverlayHint>
) : (
<OverlayHint t={t}>/ select · Enter resume · 1-9 quick · d delete · Esc/q cancel</OverlayHint>
)}
</Box>
)
}
interface SessionPickerProps {
gw: GatewayClient
onCancel: () => void
onSelect: (id: string) => void
t: Theme
}