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:
@ -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',
|
||||
|
||||
@ -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]"),
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user