From a09fa9df422df65b2214bc941617b27f1df3d220 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 10 Jun 2026 23:46:07 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20resume=20picker=20=E2=80=94=20ta?= =?UTF-8?q?bbed=20/sessions=20with=20peek=20preview=20(supersedes=20switch?= =?UTF-8?q?er)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-opentui/src/boundary/schema/SessionPeek.ts | 55 +++ ui-opentui/src/entry/main.tsx | 180 ++++--- ui-opentui/src/logic/sessionPicker.ts | 302 ++++++++++++ ui-opentui/src/logic/slash.ts | 60 ++- ui-opentui/src/logic/store.ts | 33 +- ui-opentui/src/test/render.test.tsx | 28 +- ui-opentui/src/test/sessionPicker.test.ts | 256 ++++++++++ .../src/test/sessionPickerView.test.tsx | 358 ++++++++++++++ ui-opentui/src/test/slash.test.ts | 56 ++- ui-opentui/src/test/utilityCommands.test.ts | 4 +- ui-opentui/src/view/App.tsx | 43 +- .../src/view/overlays/sessionPicker.tsx | 456 ++++++++++++++++++ .../src/view/overlays/sessionSwitcher.tsx | 60 --- 13 files changed, 1710 insertions(+), 181 deletions(-) create mode 100644 ui-opentui/src/boundary/schema/SessionPeek.ts create mode 100644 ui-opentui/src/logic/sessionPicker.ts create mode 100644 ui-opentui/src/test/sessionPicker.test.ts create mode 100644 ui-opentui/src/test/sessionPickerView.test.tsx create mode 100644 ui-opentui/src/view/overlays/sessionPicker.tsx delete mode 100644 ui-opentui/src/view/overlays/sessionSwitcher.tsx diff --git a/ui-opentui/src/boundary/schema/SessionPeek.ts b/ui-opentui/src/boundary/schema/SessionPeek.ts new file mode 100644 index 00000000000..503236bf1fc --- /dev/null +++ b/ui-opentui/src/boundary/schema/SessionPeek.ts @@ -0,0 +1,55 @@ +/** + * SessionPeek decoder — decode-at-boundary (house rule) for the `session.peek` + * RPC result (tui_gateway/server.py `@method("session.peek")`, shipped with the + * resume-picker gateway half, commit 529d8084b). The response powers the + * picker's Space preview: + * + * { session: {id, title, source, model, cwd, started_at, ended_at, + * end_reason, message_count, last_active, cost_usd}, + * head: [{id, role, content(≤2000), truncated, timestamp}, …], + * tail: [same — never overlaps head], + * total_messages: int } + * + * Wire nullability per the server: `model`/`cwd`/`ended_at`/`end_reason`/ + * `cost_usd` are `None` when unknown; message `id`/`timestamp` come straight + * off DB rows (left loose). Decoded with `Schema.decodeUnknownOption` — a + * malformed payload yields `Option.none` and the preview pane shows its + * honest "preview unavailable" line instead of crashing the overlay. + */ +import { Schema } from 'effect' + +const Str = Schema.String +const Num = Schema.Number +const opt = Schema.optionalKey + +const PeekMessageSchema = Schema.Struct({ + role: opt(Str), + content: opt(Str), + truncated: opt(Schema.Boolean), + timestamp: opt(Schema.NullOr(Schema.Unknown)) +}) + +export const SessionPeekSchema = Schema.Struct({ + session: opt( + Schema.Struct({ + id: opt(Str), + title: opt(Schema.NullOr(Str)), + source: opt(Schema.NullOr(Str)), + model: opt(Schema.NullOr(Str)), + cwd: opt(Schema.NullOr(Str)), + started_at: opt(Schema.NullOr(Num)), + ended_at: opt(Schema.NullOr(Num)), + end_reason: opt(Schema.NullOr(Str)), + message_count: opt(Schema.NullOr(Num)), + last_active: opt(Schema.NullOr(Num)), + cost_usd: opt(Schema.NullOr(Num)) + }) + ), + head: opt(Schema.Array(PeekMessageSchema)), + tail: opt(Schema.Array(PeekMessageSchema)), + total_messages: opt(Num) +}) +export type SessionPeekDecoded = typeof SessionPeekSchema.Type + +/** Decode a loose session.peek result → `Option`. */ +export const decodeSessionPeek = Schema.decodeUnknownOption(SessionPeekSchema) diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index 243a0834bc9..a57071520f0 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -35,7 +35,7 @@ import { nthAssistantResponse } from '../logic/copy.ts' import { envFlag } from '../logic/env.ts' import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts' import { createPasteStore } from '../logic/pastes.ts' -import { mapResumeHistory, mapSessionList } from '../logic/resume.ts' +import { mapResumeHistory } from '../logic/resume.ts' import { dispatchSlash, mapCompletions, @@ -47,6 +47,7 @@ import { } from '../logic/slash.ts' import { createSessionStore, type SessionStore } from '../logic/store.ts' import { App } from '../view/App.tsx' +import type { SessionPickerOps } from '../view/overlays/sessionPicker.tsx' import { ThemeProvider } from '../view/theme.tsx' import { makeFakeGatewayLayer, type FakeGatewayController } from './fakeGateway.ts' @@ -59,7 +60,9 @@ export interface TuiInput { readonly cols: number /** Optional initial prompt submitted once the session is ready — the Phase-1 stand-in for the composer. */ readonly initialPrompt?: string - /** Resume a session instead of creating one: a session id, or 'recent'/'last' (→ session.most_recent). */ + /** Resume a session instead of creating one: a session id, 'recent'/'last' + * (→ session.most_recent), or 'picker' (bare `--resume` — open the resume + * picker BEFORE any session.create; create stays lazy). */ readonly resumeId?: string } @@ -130,11 +133,67 @@ const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: stri }) }) +/** + * Post-session setup, shared by every way a session comes to exist (create, + * boot resume, boot-picker pick): the tools/skills/MCP catalog for the home + * panel (item 9 — best-effort), the optional initial prompt, and the `/model` + * catalog prefetch (Epic 7 instant open: `model.options` is the slow RPC — + * network pricing fetch + Nous tier check — so pay it ONCE in an already- + * forked fiber; the promise is STASHED in the slash seam so an early `/model` + * awaits THIS request instead of doubling it). + */ +const postSessionSetup = (gateway: GatewayServiceShape, store: SessionStore, sid: string, initialPrompt?: string) => + Effect.gen(function* () { + const catalog = yield* gateway + .request('startup.catalog', { session_id: sid }) + .pipe(Effect.catchCause(() => Effect.succeed(undefined))) + if (catalog) store.setCatalog(catalog) + + const prompt = initialPrompt?.trim() + if (prompt) { + store.pushUser(prompt) + yield* gateway.request('prompt.submit', { session_id: sid, text: prompt }) + } + + const prefetch = Effect.runPromise( + gateway + .request('model.options', { session_id: sid }) + .pipe(Effect.catchCause(() => Effect.succeed(undefined))) + ).then(modelOpts => { + const modelItems = mapModelOptions(modelOpts) + if (modelItems.length) store.setModelItems(modelItems) + }) + registerModelPrefetch(prefetch) + yield* Effect.promise(() => prefetch) + }) + +/** Create a FRESH session + run the post-session setup (the default boot path; + * also the boot-picker's Esc fallback — closing the picker without a pick + * must still leave a usable session behind). */ +const createFreshSession = (gateway: GatewayServiceShape, store: SessionStore, input: TuiInput) => + Effect.gen(function* () { + const created = yield* gateway.request<{ session_id?: string; info?: Record }>('session.create', { + cols: input.cols + }) + const sid = created?.session_id ?? gateway.sessionId() + if (!sid) { + getLog().warn('bootstrap', 'session.create returned no session_id') + return + } + if (created?.info) store.applyInfo(created.info) + writeActiveSession(sid) // record the new session for the launcher's exit epilogue (#5) + store.setSessionId(sid) + getLog().info('bootstrap', 'session created', { sid }) + yield* postSessionSetup(gateway, store, sid, input.initialPrompt) + }) + /** * Live session bootstrap: wait for the unsolicited `gateway.ready` handshake, * then either RESUME a session (hydrate its transcript — incl. tool rows — via - * the snapshot, buffering live events across the RPC) or CREATE a fresh one, and - * (if given) submit the initial prompt. Forked into the entry scope so it runs + * the snapshot, buffering live events across the RPC), open the resume PICKER + * (`resumeId === 'picker'` — bare `--resume`: no session is created until the + * user picks or closes; create is lazy), or CREATE a fresh one, and (if given) + * submit the initial prompt. Forked into the entry scope so it runs * concurrently with the render + the quit-await. Any failure is logged and * swallowed — a bootstrap hiccup must never tear down the rendered UI. */ @@ -151,9 +210,16 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp return } - let sid: string | undefined + if (input.resumeId === 'picker') { + // Boot picker (design doc §A): opens BEFORE any session.create. The pick + // resumes via onResume (which then runs postSessionSetup); a close + // without a pick falls back to createFreshSession (onSessionPickerClosed). + store.openSessionPicker('recent') + return + } + if (input.resumeId) { - sid = input.resumeId + let sid: string | undefined = input.resumeId if (sid === 'recent' || sid === 'last') { const recent = yield* gateway.request<{ session_id?: string }>('session.most_recent', {}) sid = recent?.session_id @@ -163,53 +229,11 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp return } yield* resumeInto(gateway, store, sid, input.cols) - } else { - const created = yield* gateway.request<{ session_id?: string; info?: Record }>( - 'session.create', - { cols: input.cols } - ) - sid = created?.session_id ?? gateway.sessionId() - if (!sid) { - log.warn('bootstrap', 'session.create returned no session_id') - return - } - if (created?.info) store.applyInfo(created.info) - writeActiveSession(sid) // record the new session for the launcher's exit epilogue (#5) - store.setSessionId(sid) - log.info('bootstrap', 'session created', { sid }) + yield* postSessionSetup(gateway, store, sid, input.initialPrompt) + return } - // Tools/skills/MCP catalog for the home-screen panel (item 9) — best-effort, - // never blocks startup if the RPC is missing/old. - const catalog = yield* gateway - .request('startup.catalog', { session_id: sid }) - .pipe(Effect.catchCause(() => Effect.succeed(undefined))) - if (catalog) store.setCatalog(catalog) - - const prompt = input.initialPrompt?.trim() - if (prompt) { - store.pushUser(prompt) - yield* gateway.request('prompt.submit', { session_id: sid, text: prompt }) - } - - // Prefetch the /model catalog (Epic 7 instant open): `model.options` is the - // slow RPC (it does network calls — pricing fetch + Nous tier check), so pay - // that cost ONCE here in this already-forked bootstrap fiber; `/model` then - // paints from memory on the same frame. Best-effort: if this hasn't landed - // (or the RPC is missing/old), /model falls back to fetching on first open. - // The promise is STASHED in the slash seam so a `/model` opened while the - // prefetch is still in flight awaits THIS request (bounded) instead of - // issuing a second concurrent model.options RPC (prefetch dedupe). - const prefetch = Effect.runPromise( - gateway - .request('model.options', { session_id: sid }) - .pipe(Effect.catchCause(() => Effect.succeed(undefined))) - ).then(modelOpts => { - const modelItems = mapModelOptions(modelOpts) - if (modelItems.length) store.setModelItems(modelItems) - }) - registerModelPrefetch(prefetch) - yield* Effect.promise(() => prefetch) + yield* createFreshSession(gateway, store, input) }).pipe(Effect.catchCause(cause => Effect.sync(() => getLog().warn('bootstrap', 'failed', { cause: String(cause) })))) /** The entry Effect. Mirrors opencode `app.tsx:177` `run = Effect.fn("Tui.run")`. */ @@ -384,6 +408,45 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { ) } + // Resume a chosen session (resume picker pick or `/resume ` direct + // path) — the same hydrate path as launch. When the picker was the BOOT + // surface (bare `--resume`), no create ever ran, so the post-session + // setup (catalog, /model prefetch) runs here exactly once. + const onResume = (resumeSid: string) => { + Effect.runFork( + Effect.gen(function* () { + yield* resumeInto(gateway, store, resumeSid, input.cols) + if (!store.state.catalog) yield* postSessionSetup(gateway, store, resumeSid) + }).pipe( + Effect.catchCause(cause => Effect.sync(() => getLog().warn('resume', 'failed', { cause: String(cause) }))) + ) + ) + } + + // The resume picker's gateway calls (view/overlays/sessionPicker.tsx). + // `rename` goes through `session.title` — the existing title RPC (it + // reaches only LIVE gateway sessions; the picker surfaces rejections). + const sessionOps: SessionPickerOps = { + list: params => Effect.runPromise(gateway.request('session.list', params)), + peek: sessionId => Effect.runPromise(gateway.request('session.peek', { session_id: sessionId })), + rename: (sessionId, title) => + Effect.runPromise(gateway.request('session.title', { session_id: sessionId, title })).then(() => undefined) + } + + // Boot-picker Esc fallback: the picker closed without a pick and no + // session exists yet (bare `--resume` launch) — create a fresh one so + // the composer has somewhere to send prompts. + const onSessionPickerClosed = () => { + if (gateway.sessionId()) return + Effect.runFork( + createFreshSession(gateway, store, input).pipe( + Effect.catchCause(cause => + Effect.sync(() => getLog().warn('bootstrap', 'post-picker create failed', { cause: String(cause) })) + ) + ) + ) + } + // Slash dispatch context (Solid logic; the boundary just hands it a // Promise-returning `request` + the host capabilities it needs). const slashCtx: SlashContext = { @@ -407,7 +470,6 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { flashHint(n > 1 ? `Copied response #${n} to clipboard` : 'Copied response to clipboard') return true }, - listSessions: () => Effect.runPromise(gateway.request('session.list', {})).then(mapSessionList), modelItems: () => store.state.modelItems, setModelItems: items => store.setModelItems(items), logTail: () => @@ -417,7 +479,8 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { openDashboard: () => store.openDashboard(), openPager: (title, text) => store.openPager(title, text), openPicker: picker => store.openPicker(picker), - openSwitcher: sessions => store.openSwitcher(sessions), + openSessionPicker: tab => store.openSessionPicker(tab), + resumeSession: onResume, pushSystem: text => store.pushSystem(text), quit: () => { if (!renderer.isDestroyed) renderer.destroy() @@ -427,15 +490,6 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { submit: submitPrompt } - // Resume a chosen session (session switcher pick) — same hydrate path as launch. - const onResume = (resumeSid: string) => { - Effect.runFork( - resumeInto(gateway, store, resumeSid, input.cols).pipe( - Effect.catchCause(cause => Effect.sync(() => getLog().warn('resume', 'failed', { cause: String(cause) }))) - ) - ) - } - // The composer's submit: route `/command` through the slash ladder, else a prompt. const submit = (text: string) => { if (text.startsWith('/')) void dispatchSlash(text, slashCtx) @@ -489,6 +543,8 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { onType={onType} onRespond={respond} onResume={onResume} + sessionOps={sessionOps} + onSessionPickerClosed={onSessionPickerClosed} sessionId={() => gateway.sessionId()} history={history} onImagePaste={onImagePaste} diff --git a/ui-opentui/src/logic/sessionPicker.ts b/ui-opentui/src/logic/sessionPicker.ts new file mode 100644 index 00000000000..9c7d97e753c --- /dev/null +++ b/ui-opentui/src/logic/sessionPicker.ts @@ -0,0 +1,302 @@ +/** + * sessionPicker.ts — pure logic for the tabbed resume picker (design doc + * docs/plans/opentui-resume-picker.md §A/§B item 5; supersedes the flat + * SessionSwitcher). Everything here is view-free and vitest-covered: + * + * - tab definitions + source→tab classification (Recent = interactive + * cli/tui/acp + unknown/custom; Cron = cron; Gateways = the known platform + * sources; All = everything minus the deny-listed `tool`), + * - the `session.list` params each tab queries with (`sources` allow-list — + * note the one honest gap: unknown/custom sources CLASSIFY as Recent but + * can't be expressed in an allow-list, so they surface under All), + * - the client-side search filter chain over title/preview/cwd/id (reuses + * fuzzy.ts — same scorer as the model picker), + * - the key-routing decision table (pattern: completionMenu's routeMenuKey), + * - the relative-time formatter + row-meta composer (time · source · N msgs + * · tail-truncated cwd), + * - `/sessions ` arg parsing and the `/resume ` resolver. + */ +import { fuzzyFilter, type FuzzyField } from './fuzzy.ts' + +// ── tabs + classification ───────────────────────────────────────────────── + +export type SessionTabId = 'recent' | 'cron' | 'gateways' | 'all' + +/** Tab strip order + labels (design doc §A — Recent is the default). */ +export const SESSION_TABS: ReadonlyArray<{ id: SessionTabId; label: string }> = [ + { id: 'recent', label: 'Recent' }, + { id: 'cron', label: 'Cron' }, + { id: 'gateways', label: 'Gateways' }, + { id: 'all', label: 'All' } +] + +/** Interactive sources — the Recent tab's allow-list. */ +export const INTERACTIVE_SOURCES: readonly string[] = ['cli', 'tui', 'acp'] + +/** Known platform/gateway sources (the Gateways tab's allow-list). The gateway + * itself deny-lists only `tool`, so this list is the picker's working set of + * "messaging platform" tags; new platforms join here (or show under All). */ +export const PLATFORM_SOURCES: readonly string[] = [ + 'telegram', + 'discord', + 'slack', + 'whatsapp', + 'signal', + 'imessage', + 'matrix', + 'teams', + 'email', + 'webhook', + 'x', + 'twitter', + 'mastodon', + 'irc', + 'mattermost' +] + +/** Classify a session `source` tag into its home tab (`tool` = deny-listed — + * never shown). Unknown/custom sources (incl. empty) default to Recent per + * the design table: they're assumed interactive `HERMES_SESSION_SOURCE`s. */ +export function classifySource(source: string | undefined): 'recent' | 'cron' | 'gateways' | 'tool' { + const s = (source ?? '').trim().toLowerCase() + if (s === 'tool') return 'tool' + if (s === 'cron') return 'cron' + if (PLATFORM_SOURCES.includes(s)) return 'gateways' + return 'recent' +} + +/** Whether a row with this source belongs on the given tab. */ +export function tabAccepts(tab: SessionTabId, source: string | undefined): boolean { + const cls = classifySource(source) + if (cls === 'tool') return false + return tab === 'all' || cls === tab +} + +/** + * The `session.list` params a tab queries with. Cron/Gateways push an exact + * `sources` allow-list to the gateway; All omits it (the gateway deny-lists + * `tool` itself). Recent sends the interactive allow-list — the one honest gap + * vs `classifySource` (unknown/custom sources can't be allow-listed, so they + * appear under All only); fetching everything and filtering client-side would + * make Recent unusable in cron-heavy DBs (1500+ cron rows drown the page). + */ +export function listParamsFor(tab: SessionTabId, offset: number, limit: number): Record { + const base: Record = { limit, offset } + if (tab === 'recent') return { ...base, sources: [...INTERACTIVE_SOURCES] } + if (tab === 'cron') return { ...base, sources: ['cron'] } + if (tab === 'gateways') return { ...base, sources: [...PLATFORM_SOURCES] } + return base +} + +// ── session.list row mapping ────────────────────────────────────────────── + +/** One picker row — the widened `session.list` projection (gateway 529d8084b). */ +export interface SessionRow { + id: string + title: string + preview: string + source: string + messageCount: number + startedAt: number + lastActive: number + endedAt?: number + model?: string + cwd?: string +} + +function readStr(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object') return undefined + const v = (value as { [k: string]: unknown })[key] + return typeof v === 'string' ? v : undefined +} + +function readNum(value: unknown, key: string): number { + if (!value || typeof value !== 'object') return 0 + const v = (value as { [k: string]: unknown })[key] + return typeof v === 'number' ? v : 0 +} + +/** Map a widened `session.list` result into rows + the honesty flag. */ +export function mapSessionRows(result: unknown): { rows: SessionRow[]; truncated: boolean } { + if (!result || typeof result !== 'object') return { rows: [], truncated: false } + const sessions = (result as { sessions?: unknown }).sessions + const truncated = (result as { truncated?: unknown }).truncated === true + if (!Array.isArray(sessions)) return { rows: [], truncated } + const rows: SessionRow[] = [] + for (const s of sessions) { + const id = readStr(s, 'id') + if (!id) continue + const row: SessionRow = { + id, + lastActive: readNum(s, 'last_active') || readNum(s, 'started_at'), + messageCount: readNum(s, 'message_count'), + preview: readStr(s, 'preview') ?? '', + source: readStr(s, 'source') ?? '', + startedAt: readNum(s, 'started_at'), + title: readStr(s, 'title') ?? '' + } + const endedAt = readNum(s, 'ended_at') + if (endedAt) row.endedAt = endedAt + const model = readStr(s, 'model') + if (model) row.model = model + const cwd = readStr(s, 'cwd') + if (cwd) row.cwd = cwd + rows.push(row) + } + return { rows, truncated } +} + +// ── search filter chain (client-side, within the active tab) ────────────── + +/** Fuzzy haystacks of a row: title ×2 (primary), preview, cwd, id. */ +export function sessionFields(row: SessionRow): FuzzyField[] { + const fields: FuzzyField[] = [] + if (row.title) fields.push({ text: row.title, weight: 2 }) + if (row.preview) fields.push({ text: row.preview }) + if (row.cwd) fields.push({ text: row.cwd }) + fields.push({ text: row.id }) + return fields +} + +/** Filter + rank rows by the search query (empty → all rows, fetch order). */ +export function filterSessions(query: string, rows: readonly SessionRow[]): SessionRow[] { + return fuzzyFilter(query, rows, sessionFields) +} + +// ── key routing (pattern: completionMenu.ts routeMenuKey) ──────────────── + +export interface SessionPickerKeyContext { + /** Whether the inline Ctrl+R rename is active (it owns Enter/Esc). */ + renaming: boolean + /** Whether the search query is empty (←/→ only cycle tabs when it is). */ + queryEmpty: boolean +} + +export type SessionPickerAction = + | { kind: 'close' } + | { kind: 'resume' } + | { kind: 'move'; dir: 1 | -1 } + | { kind: 'cycle-tab'; dir: 1 | -1 } + | { kind: 'preview' } + | { kind: 'rename' } + | { kind: 'commit-rename' } + | { kind: 'cancel-rename' } + | { kind: 'pass' } + +const PASS: SessionPickerAction = { kind: 'pass' } + +/** + * Route one key press. While RENAMING, the rename input owns every key except + * Enter (commit) and Esc/Ctrl+C (cancel rename — NOT close). Otherwise: + * Esc/Ctrl+C close, Enter resumes, ↑↓ (or Ctrl+P/N) move, Tab/Shift+Tab cycle + * tabs, ←/→ cycle only on an empty query (with text they stay cursor moves), + * Space toggles the preview (it never types — fuzzy terms don't need literal + * spaces), Ctrl+R starts the inline rename. Everything else belongs to the + * focused search input. + */ +export function routeSessionPickerKey( + name: string, + mods: { ctrl?: boolean; shift?: boolean }, + ctx: SessionPickerKeyContext +): SessionPickerAction { + if (ctx.renaming) { + if (name === 'return') return { kind: 'commit-rename' } + if (name === 'escape' || (mods.ctrl && name === 'c')) return { kind: 'cancel-rename' } + return PASS + } + if (name === 'escape' || (mods.ctrl && name === 'c')) return { kind: 'close' } + if (name === 'return') return { kind: 'resume' } + if (name === 'up' || (mods.ctrl && name === 'p')) return { kind: 'move', dir: -1 } + if (name === 'down' || (mods.ctrl && name === 'n')) return { kind: 'move', dir: 1 } + if (name === 'tab') return { kind: 'cycle-tab', dir: mods.shift ? -1 : 1 } + if ((name === 'left' || name === 'right') && ctx.queryEmpty) { + return { kind: 'cycle-tab', dir: name === 'left' ? -1 : 1 } + } + if (name === 'space') return { kind: 'preview' } + if (mods.ctrl && name === 'r') return { kind: 'rename' } + return PASS +} + +// ── relative time + row meta ────────────────────────────────────────────── + +/** Epoch seconds OR milliseconds → ms (DB rows are seconds; be lenient). */ +function toMs(epoch: number): number { + return epoch >= 1e12 ? epoch : epoch * 1000 +} + +const TIME_STEPS: ReadonlyArray<{ ms: number; unit: string }> = [ + { ms: 60_000, unit: 'minute' }, + { ms: 3_600_000, unit: 'hour' }, + { ms: 86_400_000, unit: 'day' }, + { ms: 604_800_000, unit: 'week' }, + { ms: 2_629_800_000, unit: 'month' }, + { ms: 31_557_600_000, unit: 'year' } +] + +/** "just now" / "1 minute ago" / "5 hours ago" / "2 weeks ago" … */ +export function relativeTime(epoch: number | undefined, nowMs: number): string { + if (!epoch) return 'unknown' + const delta = nowMs - toMs(epoch) + if (delta < 60_000) return 'just now' + for (let i = TIME_STEPS.length - 1; i >= 0; i--) { + const step = TIME_STEPS[i] + if (step && delta >= step.ms) { + const n = Math.floor(delta / step.ms) + return `${n} ${step.unit}${n === 1 ? '' : 's'} ago` + } + } + return 'just now' +} + +/** Tail-truncate a path-ish string to `max` chars (`…tail/of/path`). */ +export function tailTruncate(text: string, max: number): string { + if (text.length <= max) return text + return `…${text.slice(text.length - (max - 1))}` +} + +/** Max cwd tail shown in a row's meta line. */ +const META_CWD_MAX = 40 + +/** Row meta line: relative time · source · N msgs · cwd (when present). */ +export function rowMeta(row: SessionRow, nowMs: number): string { + const parts = [ + relativeTime(row.lastActive || row.startedAt, nowMs), + row.source || 'unknown', + `${row.messageCount} msgs` + ] + if (row.cwd) parts.push(tailTruncate(row.cwd, META_CWD_MAX)) + return parts.join(' · ') +} + +// ── slash entry points ──────────────────────────────────────────────────── + +/** Parse a `/sessions ` argument (case-insensitive, singular tolerated; + * bare/empty → the default Recent tab). Garbage → undefined (usage notice). */ +export function parseSessionTabArg(arg: string): SessionTabId | undefined { + const a = arg.trim().toLowerCase() + if (!a || a === 'recent') return 'recent' + if (a === 'cron') return 'cron' + if (a === 'gateway' || a === 'gateways') return 'gateways' + if (a === 'all') return 'all' + return undefined +} + +/** + * Resolve a `/resume ` argument against listed rows (the direct + * path): exact id → unique id prefix → exact title (case-insensitive) → + * unique case-insensitive title substring. Ambiguous/missing → undefined. + */ +export function resolveSessionArg(rows: readonly SessionRow[], arg: string): SessionRow | undefined { + const needle = arg.trim() + if (!needle) return undefined + const exactId = rows.find(r => r.id === needle) + if (exactId) return exactId + const idPrefix = rows.filter(r => r.id.startsWith(needle)) + if (idPrefix.length === 1) return idPrefix[0] + const lower = needle.toLowerCase() + const exactTitle = rows.filter(r => r.title.toLowerCase() === lower) + if (exactTitle.length === 1) return exactTitle[0] + const sub = rows.filter(r => r.title.toLowerCase().includes(lower)) + if (sub.length === 1) return sub[0] + return undefined +} diff --git a/ui-opentui/src/logic/slash.ts b/ui-opentui/src/logic/slash.ts index cda0837a119..1b69ed8e059 100644 --- a/ui-opentui/src/logic/slash.ts +++ b/ui-opentui/src/logic/slash.ts @@ -14,7 +14,8 @@ import { DETAILS_SECTIONS, DETAILS_USAGE, type DetailsMode, nextDetailsMode, parseDetailsMode } from './details.ts' import { formatBytes, memReport, performHeapdump } from './diagnostics.ts' import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from './replay.ts' -import type { CompletionItem, PickerItem, PickerState, SessionItem } from './store.ts' +import { mapSessionRows, parseSessionTabArg, resolveSessionArg, type SessionTabId } from './sessionPicker.ts' +import type { CompletionItem, PickerItem, PickerState } from './store.ts' export interface ParsedSlash { name: string @@ -48,10 +49,10 @@ export interface SlashContext { readonly quit: () => void /** Recent log lines for `/logs` (the ring buffer). */ readonly logTail: () => string[] - /** Fetch the resumable sessions (`session.list`) for the switcher. */ - readonly listSessions: () => Promise - /** Open the session switcher with the given rows. */ - readonly openSwitcher: (sessions: SessionItem[]) => void + /** Open the tabbed resume picker on the given tab (/sessions, bare /resume). */ + readonly openSessionPicker: (tab: SessionTabId) => void + /** Resume a session directly by id (`/resume ` — no picker). */ + readonly resumeSession: (sessionId: string) => void /** Open a generic picker (model picker, skills hub). */ readonly openPicker: (picker: PickerState) => void /** Open the agents dashboard (/agents, /tasks). */ @@ -153,7 +154,8 @@ const CLIENT_HELP = [ '/model [name] — switch model (picker if bare)', '/copy [n] — copy the last (or n-th) response', '/skills — browse skills', - '/sessions, /resume — switch/resume a session', + '/sessions [cron|gateways|all] — browse/resume sessions (tabbed picker)', + '/resume [id|name] — resume directly, or open the picker', '/clear, /new — clear the transcript (confirm)', '/compact [on|off|toggle] — compact transcript spacing', '/details [hidden|collapsed|expanded|cycle] — tool/reasoning detail', @@ -167,11 +169,39 @@ const CLIENT_HELP = [ type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise -/** Fetch sessions and open the switcher (shared by /sessions, /resume, /switch, /session). */ -const openSwitcher: ClientHandler = async (_arg, ctx) => { - const sessions = await ctx.listSessions() - if (sessions.length) ctx.openSwitcher(sessions) - else ctx.pushSystem('No sessions to resume.') +/** `/sessions [recent|cron|gateways|all]` — open the tabbed resume picker, + * pre-selecting the named tab (shared by /sessions, /switch, /session). */ +const sessionsCmd: ClientHandler = (arg, ctx) => { + const tab = parseSessionTabArg(arg) + if (!tab) { + ctx.pushSystem('usage: /sessions [recent|cron|gateways|all]') + return + } + ctx.openSessionPicker(tab) +} + +/** `/resume` — bare opens the picker; `/resume ` keeps the DIRECT + * path: resolve the arg against `session.list` (exact id → unique id prefix + * → exact/unique title) and hydrate without the overlay. */ +const resumeCmd: ClientHandler = async (arg, ctx) => { + const needle = arg.trim() + if (!needle) { + ctx.openSessionPicker('recent') + return + } + try { + // One bounded page over ALL sources (the gateway deny-lists `tool`) — the + // direct path targets a known session, not a browse. + const { rows } = mapSessionRows(await ctx.request('session.list', { limit: 200 })) + const hit = resolveSessionArg(rows, needle) + if (!hit) { + ctx.pushSystem(`/resume: no session matching “${needle}” — try /sessions`) + return + } + ctx.resumeSession(hit.id) + } catch (error) { + ctx.pushSystem(`/resume: ${error instanceof Error ? error.message : 'session.list failed'}`) + } } /** @@ -590,11 +620,11 @@ const CLIENT: Record = { mem: memCmd, model: modelCmd, replay: replayCmd, - resume: openSwitcher, - session: openSwitcher, - sessions: openSwitcher, + resume: resumeCmd, + session: sessionsCmd, + sessions: sessionsCmd, skills: skillsCmd, - switch: openSwitcher, + switch: sessionsCmd, tasks: (_arg, ctx) => ctx.openDashboard(), tools: toolsCmd, help: async (_arg, ctx) => { diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index 2a496e71161..c0179836718 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -23,6 +23,7 @@ import { } from '../boundary/schema/SessionInfo.ts' import type { DetailsMode } from './details.ts' import { diffStats, type DiffStats } from './diff.ts' +import type { SessionTabId } from './sessionPicker.ts' import { envOutputUnlimited } from './env.ts' import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts' import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts' @@ -98,7 +99,9 @@ export interface PagerState { text: string } -/** One row in the session switcher (from `session.list`). */ +/** One row in the legacy flat session list (from `session.list`). Kept for + * `mapSessionList` (resume.ts); the resume PICKER uses the richer + * `SessionRow` (logic/sessionPicker.ts). */ export interface SessionItem { id: string title: string @@ -106,6 +109,12 @@ export interface SessionItem { messageCount: number } +/** The open resume picker overlay (/sessions, /resume, boot `--resume`): + * just the pre-selected tab — the overlay fetches its own rows. */ +export interface SessionPickerOverlay { + tab: SessionTabId +} + /** A row in the generic picker overlay (model picker, skills hub, …). */ export interface PickerItem { label: string @@ -216,8 +225,8 @@ export interface StoreState { prompt: ActivePrompt | undefined /** The open pager overlay (replaces the transcript while set); undefined when none. */ pager: PagerState | undefined - /** The open session switcher (replaces the composer while set); undefined when none. */ - switcher: SessionItem[] | undefined + /** The open resume picker (replaces the composer while set); undefined when none. */ + sessionPicker: SessionPickerOverlay | undefined /** The open generic picker (model/skills/…); undefined when none. */ picker: PickerState | undefined /** Whether the Esc+Esc session prompt-history viewer is open (Epic 5). */ @@ -400,7 +409,7 @@ export function createSessionStore() { theme: DEFAULT_THEME, prompt: undefined, pager: undefined, - switcher: undefined, + sessionPicker: undefined, picker: undefined, promptHistory: false, completions: undefined, @@ -566,14 +575,14 @@ export function createSessionStore() { setState('pager', undefined) } - /** Open the session switcher with the given session rows (/sessions, /resume). */ - function openSwitcher(sessions: SessionItem[]) { - setState('switcher', sessions) + /** Open the resume picker on the given tab (/sessions, /resume, boot picker). */ + function openSessionPicker(tab: SessionTabId = 'recent') { + setState('sessionPicker', { tab }) } - /** Close the session switcher. */ - function closeSwitcher() { - setState('switcher', undefined) + /** Close the resume picker. */ + function closeSessionPicker() { + setState('sessionPicker', undefined) } /** Open the generic picker (model picker, skills hub, …). */ @@ -997,8 +1006,8 @@ export function createSessionStore() { setConfirm, openPager, closePager, - openSwitcher, - closeSwitcher, + openSessionPicker, + closeSessionPicker, openPicker, closePicker, openPromptHistory, diff --git a/ui-opentui/src/test/render.test.tsx b/ui-opentui/src/test/render.test.tsx index eb3f937387b..c68aa24139e 100644 --- a/ui-opentui/src/test/render.test.tsx +++ b/ui-opentui/src/test/render.test.tsx @@ -186,27 +186,37 @@ describe('App render (Phase 1, themed)', () => { expect(frame).not.toContain('Type your message') // composer hidden while the pager is open }) - test('the session switcher renders session rows and replaces the composer', async () => { + test('the resume picker renders session rows and replaces the composer', async () => { const store = createSessionStore() store.apply({ type: 'gateway.ready' }) - store.openSwitcher([ - { id: 's1', title: 'First chat', preview: 'hi', messageCount: 5 }, - { id: 's2', title: 'Second chat', preview: 'yo', messageCount: 12 } - ]) + store.openSessionPicker() + const sessionOps = { + list: () => + Promise.resolve({ + sessions: [ + { id: 's1', message_count: 5, preview: 'hi', source: 'tui', started_at: 1, title: 'First chat' }, + { id: 's2', message_count: 12, preview: 'yo', source: 'cli', started_at: 2, title: 'Second chat' } + ], + truncated: false + }), + peek: () => Promise.resolve({}), + rename: () => Promise.resolve() + } const frame = await captureFrame( () => ( store.state.theme}> - + ), - { until: 'Resume a session', width: 72, height: 18 } + { until: 'First chat', width: 72, height: 24 } ) - expect(frame).toContain('Resume a session') // switcher header + expect(frame).toContain('Resume session') // picker header expect(frame).toContain('First chat') // session row expect(frame).toContain('Second chat') - expect(frame).not.toContain('Type your message') // composer hidden while switcher open + expect(frame).toContain('[ Recent ]') // default tab chip active + expect(frame).not.toContain('Type your message') // composer hidden while the picker is open }) test('the composer shows a live slash-completions dropdown', async () => { diff --git a/ui-opentui/src/test/sessionPicker.test.ts b/ui-opentui/src/test/sessionPicker.test.ts new file mode 100644 index 00000000000..f02c4f4404e --- /dev/null +++ b/ui-opentui/src/test/sessionPicker.test.ts @@ -0,0 +1,256 @@ +/** + * Resume-picker pure logic (docs/plans/opentui-resume-picker.md §B item 5): + * source→tab classification, per-tab `session.list` params, the client-side + * search chain (title/preview/cwd/id), the key-routing decision table, the + * relative-time formatter, row-meta composition (cwd tail-truncation), the + * `/sessions ` arg parser and the `/resume ` resolver. + */ +import { describe, expect, test } from 'vitest' + +import { + classifySource, + filterSessions, + INTERACTIVE_SOURCES, + listParamsFor, + mapSessionRows, + parseSessionTabArg, + PLATFORM_SOURCES, + relativeTime, + resolveSessionArg, + routeSessionPickerKey, + rowMeta, + SESSION_TABS, + tabAccepts, + tailTruncate, + type SessionRow +} from '../logic/sessionPicker.ts' + +const row = (over: Partial): SessionRow => ({ + id: 'sid', + lastActive: 0, + messageCount: 0, + preview: '', + source: 'tui', + startedAt: 0, + title: '', + ...over +}) + +describe('tabs + classification', () => { + test('tab strip order: Recent (default) · Cron · Gateways · All', () => { + expect(SESSION_TABS.map(t => t.id)).toEqual(['recent', 'cron', 'gateways', 'all']) + }) + + test('every source classifies to its home tab', () => { + // interactive (+ unknown/custom + empty) → Recent + for (const s of ['cli', 'tui', 'acp', '', undefined, 'my-custom-source', 'TUI ']) { + expect(classifySource(s)).toBe('recent') + } + expect(classifySource('cron')).toBe('cron') + // every known platform → Gateways + for (const s of PLATFORM_SOURCES) expect(classifySource(s)).toBe('gateways') + expect(classifySource('Telegram')).toBe('gateways') // case-insensitive + // tool is deny-listed everywhere + expect(classifySource('tool')).toBe('tool') + }) + + test('tabAccepts: All = everything minus tool; others match their class', () => { + expect(tabAccepts('all', 'cli')).toBe(true) + expect(tabAccepts('all', 'cron')).toBe(true) + expect(tabAccepts('all', 'telegram')).toBe(true) + expect(tabAccepts('all', 'tool')).toBe(false) + expect(tabAccepts('recent', 'tui')).toBe(true) + expect(tabAccepts('recent', 'cron')).toBe(false) + expect(tabAccepts('cron', 'cron')).toBe(true) + expect(tabAccepts('gateways', 'discord')).toBe(true) + expect(tabAccepts('gateways', 'cli')).toBe(false) + }) + + test('listParamsFor: each tab queries with its sources allow-list', () => { + expect(listParamsFor('recent', 0, 100)).toEqual({ limit: 100, offset: 0, sources: [...INTERACTIVE_SOURCES] }) + expect(listParamsFor('cron', 200, 100)).toEqual({ limit: 100, offset: 200, sources: ['cron'] }) + expect(listParamsFor('gateways', 0, 50)).toEqual({ limit: 50, offset: 0, sources: [...PLATFORM_SOURCES] }) + expect(listParamsFor('all', 100, 100)).toEqual({ limit: 100, offset: 100 }) // gateway deny-lists tool itself + }) +}) + +describe('mapSessionRows (widened session.list projection)', () => { + test('maps rich fields; truncated flag; tolerates absent optionals + garbage', () => { + const { rows, truncated } = mapSessionRows({ + sessions: [ + { + cwd: '/home/u/proj', + ended_at: 30, + id: 's1', + last_active: 20, + message_count: 7, + model: 'hermes-4', + preview: 'hello', + source: 'tui', + started_at: 10, + title: 'First' + }, + { id: 's2', started_at: 5 }, // minimal legacy row + { title: 'no id — dropped' }, + 'garbage' + ], + truncated: true + }) + expect(truncated).toBe(true) + expect(rows).toHaveLength(2) + expect(rows[0]).toEqual({ + cwd: '/home/u/proj', + endedAt: 30, + id: 's1', + lastActive: 20, + messageCount: 7, + model: 'hermes-4', + preview: 'hello', + source: 'tui', + startedAt: 10, + title: 'First' + }) + // last_active falls back to started_at + expect(rows[1]).toMatchObject({ id: 's2', lastActive: 5, startedAt: 5 }) + expect(mapSessionRows(null)).toEqual({ rows: [], truncated: false }) + expect(mapSessionRows({ sessions: 'nope' })).toEqual({ rows: [], truncated: false }) + }) +}) + +describe('search filter chain (client-side, title/preview/cwd/id)', () => { + const rows = [ + row({ id: 'aaa-1', preview: 'fix the scrollbox', title: 'Adopt OpenTUI paradigm' }), + row({ cwd: '/home/u/worktrees/lively-thrush', id: 'bbb-2', title: 'goal-v4' }), + row({ id: 'ccc-3', preview: 'cron run output', title: '' }) + ] + + test('empty query keeps fetch order; title/preview/cwd/id all match', () => { + expect(filterSessions('', rows)).toEqual(rows) + expect(filterSessions('opentui', rows).map(r => r.id)).toEqual(['aaa-1']) + expect(filterSessions('scrollbox', rows).map(r => r.id)).toEqual(['aaa-1']) // preview + expect(filterSessions('thrush', rows).map(r => r.id)).toEqual(['bbb-2']) // cwd + expect(filterSessions('ccc-3', rows).map(r => r.id)).toEqual(['ccc-3']) // id + expect(filterSessions('zzzz', rows)).toEqual([]) + }) +}) + +describe('key routing decision table', () => { + const base = { queryEmpty: true, renaming: false } + + test('Esc/Ctrl+C close; Enter resumes; ↑↓ (and Ctrl+P/N) move', () => { + expect(routeSessionPickerKey('escape', {}, base)).toEqual({ kind: 'close' }) + expect(routeSessionPickerKey('c', { ctrl: true }, base)).toEqual({ kind: 'close' }) + expect(routeSessionPickerKey('return', {}, base)).toEqual({ kind: 'resume' }) + expect(routeSessionPickerKey('up', {}, base)).toEqual({ dir: -1, kind: 'move' }) + expect(routeSessionPickerKey('down', {}, base)).toEqual({ dir: 1, kind: 'move' }) + expect(routeSessionPickerKey('p', { ctrl: true }, base)).toEqual({ dir: -1, kind: 'move' }) + expect(routeSessionPickerKey('n', { ctrl: true }, base)).toEqual({ dir: 1, kind: 'move' }) + }) + + test('Tab/Shift+Tab cycle; ←/→ cycle ONLY on an empty query', () => { + expect(routeSessionPickerKey('tab', {}, base)).toEqual({ dir: 1, kind: 'cycle-tab' }) + expect(routeSessionPickerKey('tab', { shift: true }, base)).toEqual({ dir: -1, kind: 'cycle-tab' }) + expect(routeSessionPickerKey('left', {}, base)).toEqual({ dir: -1, kind: 'cycle-tab' }) + expect(routeSessionPickerKey('right', {}, base)).toEqual({ dir: 1, kind: 'cycle-tab' }) + const typing = { ...base, queryEmpty: false } + expect(routeSessionPickerKey('left', {}, typing)).toEqual({ kind: 'pass' }) // stays a cursor move + expect(routeSessionPickerKey('right', {}, typing)).toEqual({ kind: 'pass' }) + expect(routeSessionPickerKey('tab', {}, typing)).toEqual({ dir: 1, kind: 'cycle-tab' }) // Tab always cycles + }) + + test('Space toggles preview; Ctrl+R starts rename; printables pass to the input', () => { + expect(routeSessionPickerKey('space', {}, base)).toEqual({ kind: 'preview' }) + expect(routeSessionPickerKey('space', {}, { ...base, queryEmpty: false })).toEqual({ kind: 'preview' }) + expect(routeSessionPickerKey('r', { ctrl: true }, base)).toEqual({ kind: 'rename' }) + expect(routeSessionPickerKey('r', {}, base)).toEqual({ kind: 'pass' }) + expect(routeSessionPickerKey('a', {}, base)).toEqual({ kind: 'pass' }) + expect(routeSessionPickerKey('backspace', {}, base)).toEqual({ kind: 'pass' }) + }) + + test('while RENAMING the rename input owns everything but Enter/Esc', () => { + const renaming = { queryEmpty: true, renaming: true } + expect(routeSessionPickerKey('return', {}, renaming)).toEqual({ kind: 'commit-rename' }) + expect(routeSessionPickerKey('escape', {}, renaming)).toEqual({ kind: 'cancel-rename' }) + expect(routeSessionPickerKey('c', { ctrl: true }, renaming)).toEqual({ kind: 'cancel-rename' }) + expect(routeSessionPickerKey('up', {}, renaming)).toEqual({ kind: 'pass' }) + expect(routeSessionPickerKey('tab', {}, renaming)).toEqual({ kind: 'pass' }) + expect(routeSessionPickerKey('space', {}, renaming)).toEqual({ kind: 'pass' }) + expect(routeSessionPickerKey('a', {}, renaming)).toEqual({ kind: 'pass' }) + }) +}) + +describe('relative time + row meta', () => { + const now = 1_760_000_000_000 // ms + + test('relativeTime: steps, singular/plural, seconds vs ms epochs', () => { + const sec = now / 1000 + expect(relativeTime(sec - 10, now)).toBe('just now') + expect(relativeTime(sec - 60, now)).toBe('1 minute ago') + expect(relativeTime(sec - 5 * 60, now)).toBe('5 minutes ago') + expect(relativeTime(sec - 3600, now)).toBe('1 hour ago') + expect(relativeTime(sec - 26 * 3600, now)).toBe('1 day ago') + expect(relativeTime(sec - 8 * 86_400, now)).toBe('1 week ago') + expect(relativeTime(sec - 40 * 86_400, now)).toBe('1 month ago') + expect(relativeTime(sec - 800 * 86_400, now)).toBe('2 years ago') + expect(relativeTime(now - 3_600_000, now)).toBe('1 hour ago') // ms epoch tolerated + expect(relativeTime(0, now)).toBe('unknown') + expect(relativeTime(undefined, now)).toBe('unknown') + }) + + test('tailTruncate keeps the path TAIL under the cap', () => { + expect(tailTruncate('short', 10)).toBe('short') + const long = '/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent' + const cut = tailTruncate(long, 30) + expect(cut).toHaveLength(30) + expect(cut.startsWith('…')).toBe(true) + expect(cut.endsWith('hermes-agent')).toBe(true) + }) + + test('rowMeta: time · source · msgs · truncated cwd (cwd omitted when absent)', () => { + const sec = now / 1000 + const meta = rowMeta( + row({ + cwd: '/home/daimon/github/worktrees/hermes-agent/lively-thrush/hermes-agent', + lastActive: sec - 3600, + messageCount: 142, + source: 'tui' + }), + now + ) + expect(meta).toContain('1 hour ago · tui · 142 msgs · …') + expect(meta).toContain('lively-thrush/hermes-agent') + const bare = rowMeta(row({ lastActive: sec - 60, messageCount: 9, source: 'cli' }), now) + expect(bare).toBe('1 minute ago · cli · 9 msgs') + // lastActive falls back to startedAt; empty source reads "unknown" + expect(rowMeta(row({ lastActive: 0, messageCount: 1, source: '', startedAt: sec - 60 }), now)).toBe( + '1 minute ago · unknown · 1 msgs' + ) + }) +}) + +describe('slash entry points', () => { + test('parseSessionTabArg: bare → recent; names ci; singular gateway; garbage → undefined', () => { + expect(parseSessionTabArg('')).toBe('recent') + expect(parseSessionTabArg('recent')).toBe('recent') + expect(parseSessionTabArg('CRON')).toBe('cron') + expect(parseSessionTabArg('gateway')).toBe('gateways') + expect(parseSessionTabArg('gateways')).toBe('gateways') + expect(parseSessionTabArg('all')).toBe('all') + expect(parseSessionTabArg('bogus')).toBeUndefined() + }) + + test('resolveSessionArg: exact id → unique id prefix → exact title → unique substring', () => { + const rows = [ + row({ id: 'abc-123', title: 'Adopt OpenTUI' }), + row({ id: 'abd-456', title: 'goal-v4' }), + row({ id: 'xyz-789', title: 'Goal-V4 retry' }) + ] + expect(resolveSessionArg(rows, 'abc-123')?.id).toBe('abc-123') // exact id + expect(resolveSessionArg(rows, 'xyz')?.id).toBe('xyz-789') // unique prefix + expect(resolveSessionArg(rows, 'ab')).toBeUndefined() // ambiguous prefix + expect(resolveSessionArg(rows, 'goal-v4')?.id).toBe('abd-456') // exact title beats substring + expect(resolveSessionArg(rows, 'adopt')?.id).toBe('abc-123') // unique substring + expect(resolveSessionArg(rows, 'goal')).toBeUndefined() // ambiguous substring + expect(resolveSessionArg(rows, '')).toBeUndefined() + }) +}) diff --git a/ui-opentui/src/test/sessionPickerView.test.tsx b/ui-opentui/src/test/sessionPickerView.test.tsx new file mode 100644 index 00000000000..2deaf574e28 --- /dev/null +++ b/ui-opentui/src/test/sessionPickerView.test.tsx @@ -0,0 +1,358 @@ +/** + * Resume-picker overlay tests (design doc §B item 6) — headless frames with a + * simulated keyboard through the REAL component against fake gateway ops: + * opens on the Recent tab querying the interactive sources; Tab cycling + * re-queries with each tab's `sources`; typing filters CLIENT-side (no + * re-query); Space opens the peek preview and issues exactly ONE peek per + * settled highlight (stale responses dropped); Enter resumes the highlighted + * id; Ctrl+R renames inline via the rename op; `initialTab` pre-selects; + * `truncated: true` renders the honesty row; a full page offers "load more" + * which fetches the next offset; Esc closes (but only cancels an open rename). + */ +import { describe, expect, test } from 'vitest' + +import { INTERACTIVE_SOURCES, PLATFORM_SOURCES } from '../logic/sessionPicker.ts' +import { DEFAULT_THEME } from '../logic/theme.ts' +import { SessionPicker, type SessionPickerOps } from '../view/overlays/sessionPicker.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { renderProbe, type RenderProbe } from './lib/render.ts' + +const NOW_S = Math.floor(Date.now() / 1000) + +interface FakeSession { + id: string + title?: string + preview?: string + source?: string + message_count?: number + started_at?: number + last_active?: number + cwd?: string +} + +const SESSIONS: FakeSession[] = [ + { + cwd: '/home/u/worktrees/lively-thrush/hermes-agent', + id: 's1', + last_active: NOW_S - 3600, + message_count: 142, + preview: 'rebuild the TUI', + source: 'tui', + started_at: NOW_S - 7200, + title: 'Adopt OpenTUI paradigm' + }, + { + id: 's2', + last_active: NOW_S - 60, + message_count: 9, + preview: 'goal work', + source: 'cli', + started_at: NOW_S - 120, + title: 'goal-v4' + }, + { + id: 's3', + last_active: NOW_S - 86_400, + message_count: 3, + preview: 'an older chat', + source: 'tui', + started_at: NOW_S - 90_000, + title: 'older session' + } +] + +interface Harness { + probe: RenderProbe + listCalls: Record[] + peekCalls: string[] + renameCalls: Array<{ id: string; title: string }> + resumed: string[] + closed: { value: boolean } +} + +interface MountOptions { + sessions?: FakeSession[] + truncated?: boolean + initialTab?: 'recent' | 'cron' | 'gateways' | 'all' + /** Per-id peek delay (ms) — drives the stale-cancellation test. */ + peekDelay?: (id: string) => number + /** Paged list: serve `sessions` windowed by offset/limit instead of whole. */ + paged?: boolean +} + +async function mountPicker(options: MountOptions = {}): Promise { + const sessions = options.sessions ?? SESSIONS + const listCalls: Record[] = [] + const peekCalls: string[] = [] + const renameCalls: Array<{ id: string; title: string }> = [] + const resumed: string[] = [] + const closed = { value: false } + const ops: SessionPickerOps = { + list: params => { + listCalls.push(params) + const offset = typeof params.offset === 'number' ? params.offset : 0 + const limit = typeof params.limit === 'number' ? params.limit : sessions.length + const page = options.paged ? sessions.slice(offset, offset + limit) : sessions + return Promise.resolve({ sessions: page, truncated: options.truncated ?? false }) + }, + peek: id => { + peekCalls.push(id) + const payload = { + head: [{ content: `peek-head-${id}`, role: 'user', truncated: false }], + session: { cwd: '/home/u/proj', id, message_count: 5, model: 'hermes-4', title: 'x' }, + tail: [{ content: `peek-tail-${id}`, role: 'assistant', truncated: false }], + total_messages: 5 + } + const delay = options.peekDelay?.(id) ?? 0 + if (!delay) return Promise.resolve(payload) + return new Promise(resolve => setTimeout(() => resolve(payload), delay)) + }, + rename: (id, title) => { + renameCalls.push({ id, title }) + return Promise.resolve() + } + } + const probe = await renderProbe( + () => ( + DEFAULT_THEME}> + resumed.push(id)} + onClose={() => (closed.value = true)} + /> + + ), + // kitty keyboard so a SIMULATED lone Esc parses (see lib/render.ts) + { height: 30, kittyKeyboard: true, width: 90 } + ) + // the initial session.list resolves async — wait for the rows (or the empty + // state) to paint before handing the probe to the test. + await probe.waitForFrame(f => !f.includes('loading…')) + return { closed, listCalls, peekCalls, probe, renameCalls, resumed } +} + +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +describe('SessionPicker — open + rows', () => { + test('opens on the Recent tab, queries the interactive sources, renders title + meta rows', async () => { + const h = await mountPicker() + try { + expect(h.listCalls).toHaveLength(1) + expect(h.listCalls[0]).toEqual({ limit: 100, offset: 0, sources: [...INTERACTIVE_SOURCES] }) + const frame = h.probe.frame() + expect(frame).toContain('Resume session (3 of 3)') + expect(frame).toContain('[ Recent ]') // active chip + expect(frame).toContain('❯ Adopt OpenTUI paradigm') // first row selected + expect(frame).toContain('1 hour ago · tui · 142 msgs · …') // meta line w/ truncated cwd + expect(frame).toContain('lively-thrush/hermes-agent') + expect(frame).toContain('1 minute ago · cli · 9 msgs') // cwd-less meta + expect(frame).toContain('↑↓ select · Enter resume · Tab scope · Space preview · Ctrl+R rename · Esc cancel') + } finally { + h.probe.destroy() + } + }) + + test('initialTab cron pre-selects the chip and queries sources [cron] (/sessions cron)', async () => { + const h = await mountPicker({ initialTab: 'cron', sessions: [] }) + try { + expect(h.listCalls[0]).toEqual({ limit: 100, offset: 0, sources: ['cron'] }) + const frame = h.probe.frame() + expect(frame).toContain('[ Cron ]') + expect(frame).toContain('(no sessions on this tab)') + } finally { + h.probe.destroy() + } + }) + + test('truncated: true renders the honesty row', async () => { + const h = await mountPicker({ truncated: true }) + try { + expect(h.probe.frame()).toContain('results truncated — narrow the search') + } finally { + h.probe.destroy() + } + }) +}) + +describe('SessionPicker — tab cycling re-queries', () => { + test('Tab walks Recent→Cron→Gateways→All with each tab’s sources; Shift+Tab wraps back', async () => { + const h = await mountPicker() + try { + h.probe.keys.pressTab() + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('[ Cron ]')) + expect(h.listCalls[1]).toMatchObject({ offset: 0, sources: ['cron'] }) + h.probe.keys.pressTab() + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('[ Gateways ]')) + expect(h.listCalls[2]).toMatchObject({ sources: [...PLATFORM_SOURCES] }) + h.probe.keys.pressTab() + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('[ All ]')) + expect(h.listCalls[3]).toEqual({ limit: 100, offset: 0 }) // All: no sources filter + h.probe.keys.pressTab({ shift: true }) // wrap back → Gateways + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('[ Gateways ]')) + expect(h.listCalls).toHaveLength(5) + } finally { + h.probe.destroy() + } + }) +}) + +describe('SessionPicker — search filters client-side', () => { + test('typing narrows rows (title/cwd match) without re-querying the gateway', async () => { + const h = await mountPicker() + try { + await h.probe.keys.typeText('goal') + await h.probe.settle() + const frame = await h.probe.waitForFrame(f => !f.includes('Adopt OpenTUI')) + expect(frame).toContain('❯ goal-v4') + expect(frame).toContain('Resume session (1 of 3)') + expect(h.listCalls).toHaveLength(1) // no re-query — pure client filter + for (let i = 0; i < 4; i++) h.probe.keys.pressBackspace() + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('Adopt OpenTUI')) + } finally { + h.probe.destroy() + } + }) +}) + +describe('SessionPicker — Enter resumes', () => { + test('↓ then Enter resumes the highlighted session id', async () => { + const h = await mountPicker() + try { + h.probe.keys.pressArrow('down') + await h.probe.settle() + expect(h.probe.frame()).toContain('❯ goal-v4') + h.probe.keys.pressEnter() + await h.probe.settle() + expect(h.resumed).toEqual(['s2']) + } finally { + h.probe.destroy() + } + }) +}) + +describe('SessionPicker — Space preview (peek)', () => { + test('Space opens the pane with ONE immediate peek; Space again closes it', async () => { + const h = await mountPicker() + try { + h.probe.keys.pressKey(' ') + await h.probe.settle() + const frame = await h.probe.waitForFrame(f => f.includes('peek-head-s1')) + expect(frame).toContain('preview (Space)') + expect(frame).toContain('hermes-4') // session meta line + expect(frame).toContain('peek-tail-s1') + expect(h.peekCalls).toEqual(['s1']) + // Space never types into the search input (it toggles the pane instead) + expect(frame).toContain('Resume session (3 of 3)') + h.probe.keys.pressKey(' ') + await h.probe.settle() + expect(h.probe.frame()).not.toContain('preview (Space)') + expect(h.peekCalls).toEqual(['s1']) + } finally { + h.probe.destroy() + } + }) + + test('scrolling while open debounces to ONE peek for the settled row; stale responses are dropped', async () => { + // s1's response is SLOW — it resolves after s3's and must be discarded. + const h = await mountPicker({ peekDelay: id => (id === 's1' ? 120 : 0) }) + try { + h.probe.keys.pressKey(' ') // immediate peek for s1 (slow) + await h.probe.settle() + h.probe.keys.pressArrow('down') // s2 — debounced… + h.probe.keys.pressArrow('down') // …superseded by s3 within the window + await h.probe.settle() + await wait(60) // debounce (20ms) fires once for s3 + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('peek-head-s3')) + expect(h.peekCalls).toEqual(['s1', 's3']) // exactly one peek per settled highlight + await wait(100) // let the stale s1 response land + await h.probe.settle() + expect(h.probe.frame()).toContain('peek-head-s3') // not clobbered by stale s1 + expect(h.probe.frame()).not.toContain('peek-head-s1') + } finally { + h.probe.destroy() + } + }) +}) + +describe('SessionPicker — Ctrl+R inline rename', () => { + test('Ctrl+R opens the prefilled rename line; Enter commits via session.title and updates the row', async () => { + const h = await mountPicker() + try { + h.probe.keys.pressKey('r', { ctrl: true }) + await h.probe.settle() + let frame = await h.probe.waitForFrame(f => f.includes('rename:')) + expect(frame).toContain('Enter save · Esc cancel rename') + await h.probe.keys.typeText(' v2') // appends to the prefilled current title + await h.probe.settle() + h.probe.keys.pressEnter() + await h.probe.settle() + expect(h.renameCalls).toEqual([{ id: 's1', title: 'Adopt OpenTUI paradigm v2' }]) + expect(h.resumed).toEqual([]) // Enter committed the rename, not a resume + frame = await h.probe.waitForFrame(f => f.includes('Adopt OpenTUI paradigm v2')) + expect(frame).not.toContain('rename:') + } finally { + h.probe.destroy() + } + }) + + test('Esc cancels the rename WITHOUT closing the picker; a later Esc closes it', async () => { + const h = await mountPicker() + try { + h.probe.keys.pressKey('r', { ctrl: true }) + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('rename:')) + h.probe.keys.pressEscape() + await h.probe.settle() + const frame = await h.probe.waitForFrame(f => !f.includes('rename:')) + expect(h.closed.value).toBe(false) // picker stayed open + expect(frame).toContain('Resume session') + await wait(60) // the once-per-press Esc guard window + h.probe.keys.pressEscape() + await h.probe.settle() + await h.probe.settle() + expect(h.closed.value).toBe(true) + expect(h.renameCalls).toEqual([]) + } finally { + h.probe.destroy() + } + }) +}) + +describe('SessionPicker — pagination', () => { + test('a full page offers “load more”; Enter on it fetches the next offset', async () => { + // 100 rows fill page one exactly → the load-more row appears. + const many: FakeSession[] = Array.from({ length: 120 }, (_, i) => ({ + id: `bulk-${i}`, + last_active: NOW_S - i * 60, + message_count: i, + source: 'tui', + started_at: NOW_S - i * 60, + title: `bulk session ${i}` + })) + const h = await mountPicker({ paged: true, sessions: many }) + try { + let frame = h.probe.frame() + expect(frame).toContain('Resume session (100 of 100+)') // page full → maybe more + h.probe.keys.pressArrow('up') // wrap to the LAST selectable = the load-more row (scrolls into view) + await h.probe.settle() + expect(h.probe.frame()).toContain('❯ ↓ load more (100 loaded)') + h.probe.keys.pressEnter() + await h.probe.settle() + frame = await h.probe.waitForFrame(f => f.includes('(120 of 120)')) + expect(h.listCalls).toHaveLength(2) + expect(h.listCalls[1]).toMatchObject({ limit: 100, offset: 100 }) + expect(frame).not.toContain('load more') // short second page → exhausted + expect(h.resumed).toEqual([]) // Enter loaded, never resumed + } finally { + h.probe.destroy() + } + }) +}) diff --git a/ui-opentui/src/test/slash.test.ts b/ui-opentui/src/test/slash.test.ts index fb2994f8531..06b5c9745a4 100644 --- a/ui-opentui/src/test/slash.test.ts +++ b/ui-opentui/src/test/slash.test.ts @@ -19,9 +19,8 @@ import { runPickerRefresh, type SlashContext } from '../logic/slash.ts' -import type { PickerItem, SessionItem } from '../logic/store.ts' - -const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }] +import type { SessionTabId } from '../logic/sessionPicker.ts' +import type { PickerItem } from '../logic/store.ts' // the picker-refresh/tabs/prefetch seams are module-level state — never leak them across tests afterEach(() => { @@ -137,7 +136,8 @@ interface Probe { submitted: string[] confirmed: Array<{ message: string; onConfirm: () => void }> paged: Array<{ title: string; text: string }> - switched: SessionItem[][] + sessionPickers: SessionTabId[] + resumed: string[] pickers: Array<{ title: string; items: PickerItem[]; onPick: (value: string) => void }> quit: { value: boolean } cleared: { value: boolean } @@ -157,7 +157,8 @@ function makeCtx(request: (method: string, params: Record) => P const submitted: string[] = [] const confirmed: Probe['confirmed'] = [] const paged: Probe['paged'] = [] - const switched: Probe['switched'] = [] + const sessionPickers: SessionTabId[] = [] + const resumed: string[] = [] const pickers: Probe['pickers'] = [] const quit = { value: false } const cleared = { value: false } @@ -179,14 +180,14 @@ function makeCtx(request: (method: string, params: Record) => P copied.push(n) return copyN.value(n) }, - listSessions: () => Promise.resolve(FAKE_SESSIONS), logTail: () => ['gateway: spawned', 'bootstrap: session created'], modelItems: () => modelCache.value, setModelItems: items => (modelCache.value = items), openDashboard: () => (dashboard.value = true), openPager: (title, text) => paged.push({ text, title }), openPicker: p => pickers.push(p), - openSwitcher: sessions => switched.push(sessions), + openSessionPicker: tab => sessionPickers.push(tab), + resumeSession: id => resumed.push(id), pushSystem: text => system.push(text), quit: () => (quit.value = true), request: (method, params) => { @@ -210,8 +211,9 @@ function makeCtx(request: (method: string, params: Record) => P paged, pickers, quit, + resumed, + sessionPickers, submitted, - switched, system } } @@ -240,14 +242,44 @@ describe('dispatchSlash — client commands', () => { expect(p.paged[0]?.text).toContain('session created') }) - test('/sessions (and /resume) open the switcher with session.list rows', async () => { + test('/sessions (and bare /resume) open the resume picker on the Recent tab', async () => { const p = makeCtx(async () => ({})) await dispatchSlash('/sessions', p.ctx) - expect(p.switched).toHaveLength(1) - expect(p.switched[0]).toEqual(FAKE_SESSIONS) + expect(p.sessionPickers).toEqual(['recent']) + expect(p.calls).toHaveLength(0) // the overlay fetches its own rows const p2 = makeCtx(async () => ({})) await dispatchSlash('/resume', p2.ctx) - expect(p2.switched).toHaveLength(1) + expect(p2.sessionPickers).toEqual(['recent']) + }) + + test('/sessions cron|gateways pre-select that tab; garbage -> usage', async () => { + const p = makeCtx(async () => ({})) + await dispatchSlash('/sessions cron', p.ctx) + await dispatchSlash('/sessions gateways', p.ctx) + await dispatchSlash('/sessions all', p.ctx) + expect(p.sessionPickers).toEqual(['cron', 'gateways', 'all']) + await dispatchSlash('/sessions bogus', p.ctx) + expect(p.sessionPickers).toHaveLength(3) + expect(p.system.at(-1)).toContain('usage: /sessions') + }) + + test('/resume keeps the DIRECT path: resolves against session.list and resumes', async () => { + const rows = { + sessions: [ + { id: 'abc-123', message_count: 5, preview: 'hello', source: 'tui', started_at: 1, title: 'First chat' }, + { id: 'def-456', message_count: 2, preview: 'yo', source: 'cli', started_at: 2, title: 'Goal v4' } + ], + truncated: false + } + const p = makeCtx(async method => (method === 'session.list' ? rows : {})) + await dispatchSlash('/resume abc-123', p.ctx) // exact id + await dispatchSlash('/resume def', p.ctx) // unique id prefix + await dispatchSlash('/resume goal v4', p.ctx) // exact title (ci) + expect(p.resumed).toEqual(['abc-123', 'def-456', 'def-456']) + expect(p.sessionPickers).toHaveLength(0) // never opened the overlay + await dispatchSlash('/resume nope', p.ctx) + expect(p.resumed).toHaveLength(3) + expect(p.system.at(-1)).toContain('no session matching') }) test('/model (bare) opens a GROUPED picker of authenticated providers’ models; pick switches', async () => { diff --git a/ui-opentui/src/test/utilityCommands.test.ts b/ui-opentui/src/test/utilityCommands.test.ts index ce0d3fab370..cd6e03bc3bf 100644 --- a/ui-opentui/src/test/utilityCommands.test.ts +++ b/ui-opentui/src/test/utilityCommands.test.ts @@ -52,14 +52,14 @@ function makeCtx(request: (method: string, params: Record) => P renderableCount: () => renderables.value, confirm: () => {}, copyResponse: () => false, - listSessions: () => Promise.resolve([]), logTail: () => [], modelItems: () => undefined, setModelItems: () => {}, openDashboard: () => {}, openPager: (title, text) => paged.push({ text, title }), openPicker: () => {}, - openSwitcher: () => {}, + openSessionPicker: () => {}, + resumeSession: () => {}, pushSystem: text => system.push(text), quit: () => {}, request: (method, params) => { diff --git a/ui-opentui/src/view/App.tsx b/ui-opentui/src/view/App.tsx index 647f65912ee..82b6e6b29fe 100644 --- a/ui-opentui/src/view/App.tsx +++ b/ui-opentui/src/view/App.tsx @@ -1,13 +1,14 @@ /** * App — the Solid view shell (spec v4 §2 `view/App.tsx`). Header + a content zone * that is either the PAGER overlay (long slash output) or the normal - * transcript + input zone; the input zone is one of: blocking prompt, session - * switcher, generic picker (model/skills), or the composer. Fully themed (§7.5). + * transcript + input zone; the input zone is one of: blocking prompt, resume + * picker (/sessions), generic picker (model/skills), or the composer. Fully + * themed (§7.5). * * header flexShrink:0 (top chrome line) * content flexGrow:1, minHeight:0 — Pager OR (transcript + input zone) * transcript flexGrow:1, minHeight:0 (the one ; §8 #2 gotchas) - * input zone flexShrink:0 (PromptOverlay | SessionSwitcher | Picker | Composer) + * input zone flexShrink:0 (PromptOverlay | SessionPicker | Picker | Composer) * * Overlays REPLACE rather than stack (a ``), so the composer remounts + * refocuses when an overlay closes; the key that closed an overlay can't leak @@ -28,7 +29,7 @@ import { AgentsDashboard } from './overlays/agentsDashboard.tsx' import { Pager } from './overlays/pager.tsx' import { Picker } from './overlays/picker.tsx' import { PromptHistory } from './overlays/promptHistory.tsx' -import { SessionSwitcher } from './overlays/sessionSwitcher.tsx' +import { SessionPicker, type SessionPickerOps } from './overlays/sessionPicker.tsx' import { PromptOverlay } from './prompts/promptOverlay.tsx' import { SessionInfoProvider } from './sessionInfo.tsx' import { StatusBar } from './statusBar.tsx' @@ -42,6 +43,11 @@ export interface AppProps { readonly onType?: (text: string) => void readonly onRespond?: (method: string, params: Record) => void readonly onResume?: (sessionId: string) => void + /** Gateway calls for the resume picker (session.list/peek/title). */ + readonly sessionOps?: SessionPickerOps + /** Fired after the resume picker closes WITHOUT a pick (boot path: the + * entry creates a fresh session when none exists yet). */ + readonly onSessionPickerClosed?: () => void readonly sessionId?: () => string | undefined readonly history?: ComposerHistory readonly onImagePaste?: () => void @@ -52,6 +58,12 @@ const NOOP = () => {} const NOOP_RESPOND = () => {} const NOOP_RESUME = () => {} const NO_SESSION = () => undefined +/** Inert picker ops for headless mounts that pass no gateway (tests). */ +const NOOP_OPS: SessionPickerOps = { + list: () => Promise.resolve({ sessions: [], truncated: false }), + peek: () => Promise.resolve({}), + rename: () => Promise.resolve() +} export function App(props: AppProps) { const theme = useTheme() @@ -63,14 +75,19 @@ export function App(props: AppProps) { const blocked = () => props.store.state.prompt !== undefined const pager = () => props.store.state.pager const dashboard = () => props.store.state.dashboard - const switcher = () => props.store.state.switcher + const sessionPicker = () => props.store.state.sessionPicker const picker = () => props.store.state.picker const promptHistory = () => props.store.state.promptHistory // Defer the close so the key that closed an overlay (Esc/q/Enter) can't land in // the freshly-remounted composer (see deferClose). const closePager = () => deferClose(() => props.store.closePager()) const closeDashboard = () => deferClose(() => props.store.closeDashboard()) - const closeSwitcher = () => deferClose(() => props.store.closeSwitcher()) + // close WITHOUT a pick — the boot path may create a fresh session here. + const closeSessionPicker = () => + deferClose(() => { + props.store.closeSessionPicker() + props.onSessionPickerClosed?.() + }) const closePicker = () => deferClose(() => props.store.closePicker()) const closePromptHistory = () => deferClose(() => props.store.closePromptHistory()) // Esc+Esc viewer trigger (Epic 5): only when this session HAS user prompts — @@ -80,7 +97,8 @@ export function App(props: AppProps) { } const resume = (id: string) => { ;(props.onResume ?? NOOP_RESUME)(id) - closeSwitcher() + // a PICK closes without the no-pick callback (the resume owns the session) + deferClose(() => props.store.closeSessionPicker()) } return ( @@ -133,8 +151,15 @@ export function App(props: AppProps) { sessionId={props.sessionId ?? NO_SESSION} /> - - {sessions => } + + {sp => ( + + )} {p => ( diff --git a/ui-opentui/src/view/overlays/sessionPicker.tsx b/ui-opentui/src/view/overlays/sessionPicker.tsx new file mode 100644 index 00000000000..bca1c70129e --- /dev/null +++ b/ui-opentui/src/view/overlays/sessionPicker.tsx @@ -0,0 +1,456 @@ +/** + * SessionPicker — the tabbed resume picker (design doc + * docs/plans/opentui-resume-picker.md §A; supersedes SessionSwitcher). + * + * Layout per the §A mock: search `` (focused throughout — the model + * picker's input+global-useKeyboard discipline, view/overlays/picker.tsx), + * the reusable TabChips strip (Recent/Cron/Gateways/All), a windowed row list + * driven programmatically (↑↓/Enter from the global key handler with + * preventDefault, so the input never also applies them), a Space-toggled + * preview pane fed by `session.peek`, Ctrl+R inline rename via + * `session.title`, and the spec footer. Esc/Ctrl+C close through BOTH the + * keymap layer and the global handler (picker pattern) — funneled through a + * once-per-press guard so one Esc can't cancel the rename AND close. + * + * Data flow: each tab queries `session.list` with its `sources` allow-list + * (logic/sessionPicker.ts `listParamsFor`); the search filters CLIENT-side + * within the fetched rows (fuzzy over title/preview/cwd/id). Pagination is a + * selectable "load more" row (offset = rows fetched so far) — shown until a + * short page signals exhaustion; the gateway's `truncated` flag renders an + * honest notice instead of pretending the page is final. + * + * Peek debounce: selection changes while the preview is open schedule the + * peek after PEEK_DEBOUNCE_MS (holding ↓ scrolls without a peek per row); + * the pending timer is cleared on every change and a monotonic seq drops + * stale responses, so exactly one peek lands per SETTLED highlight. + */ +import type { BoxRenderable, InputRenderable } from '@opentui/core' +import { useKeyboard } from '@opentui/solid' +import { Option } from 'effect' +import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from 'solid-js' + +import { decodeSessionPeek, type SessionPeekDecoded } from '../../boundary/schema/SessionPeek.ts' +import { visibleRows, type PickerRow } from '../../logic/fuzzy.ts' +import { + filterSessions, + listParamsFor, + mapSessionRows, + relativeTime, + routeSessionPickerKey, + rowMeta, + SESSION_TABS, + tailTruncate, + type SessionRow, + type SessionTabId +} from '../../logic/sessionPicker.ts' +import { useCloseLayer } from '../keymap.tsx' +import { useTheme } from '../theme.tsx' +import { TabChips } from './picker.tsx' + +/** Gateway calls the picker needs (wired from the entry; fakeable in tests). */ +export interface SessionPickerOps { + /** Raw `session.list` result for the given params. */ + readonly list: (params: Record) => Promise + /** Raw `session.peek` result for a session id. */ + readonly peek: (sessionId: string) => Promise + /** Rename via `session.title` (rejects when the gateway can't — surfaced). */ + readonly rename: (sessionId: string, title: string) => Promise +} + +/** Page size per `session.list` fetch (a "load more" row pulls the next). */ +const PAGE_SIZE = 100 +/** Debounce for preview refresh while the selection changes (ms). */ +export const PEEK_DEBOUNCE_MS = 120 +/** Max session rows visible at once (each row renders 2 lines). */ +const MAX_ROWS = 8 +/** Max visible rows while the preview pane is open (it needs the space). */ +const MAX_ROWS_PREVIEW = 4 +/** One-line cap for preview message excerpts. */ +const PEEK_EXCERPT = 110 + +/** First line of a message, capped — preview rows must stay one line each. */ +function excerpt(text: string): string { + const line = text.split('\n', 1)[0] ?? '' + return line.length > PEEK_EXCERPT ? `${line.slice(0, PEEK_EXCERPT - 1)}…` : line +} + +export function SessionPicker(props: { + ops: SessionPickerOps + onResume: (sessionId: string) => void + onClose: () => void + initialTab?: SessionTabId + /** Test seam: override the peek debounce (default PEEK_DEBOUNCE_MS). */ + peekDebounceMs?: number +}) { + const theme = useTheme() + let rootRef: BoxRenderable | undefined + let searchRef: InputRenderable | undefined + let renameRef: InputRenderable | undefined + + // ── core state ────────────────────────────────────────────────────────── + const [tab, setTab] = createSignal(props.initialTab ?? 'recent') + const [query, setQuery] = createSignal('') + const [rows, setRows] = createSignal([]) + const [truncated, setTruncated] = createSignal(false) + const [exhausted, setExhausted] = createSignal(false) + const [loading, setLoading] = createSignal(false) + const [listError, setListError] = createSignal(false) + const [sel, setSel] = createSignal(0) + // transient note line (rename failures and the like) + const [note, setNote] = createSignal() + + // ── session.list fetching (per tab; "load more" appends the next page) ── + // A monotonic seq drops responses from a superseded tab/page request. + let listSeq = 0 + const fetchPage = (offset: number) => { + const seq = ++listSeq + setLoading(true) + setListError(false) + props.ops + .list(listParamsFor(tab(), offset, PAGE_SIZE)) + .then(raw => { + if (seq !== listSeq) return + const page = mapSessionRows(raw) + setRows(prev => (offset === 0 ? page.rows : [...prev, ...page.rows])) + setTruncated(page.truncated) + setExhausted(page.rows.length < PAGE_SIZE) + }) + .catch(() => { + if (seq === listSeq) setListError(true) + }) + .finally(() => { + if (seq === listSeq) setLoading(false) + }) + } + // Tab activation (incl. the initial one) resets and re-queries with that + // tab's `sources` — search-within-tab, so the query survives the switch. + createEffect( + on(tab, () => { + setRows([]) + setTruncated(false) + setExhausted(false) + setSel(0) + fetchPage(0) + }) + ) + + const filtered = createMemo(() => filterSessions(query(), rows())) + createEffect(on(query, () => setSel(0), { defer: true })) + + // "load more" pseudo-row: selectable index === filtered().length. Offered + // until a short page proves the tab exhausted (query or not — more matches + // may live in unfetched pages). + const hasMore = createMemo(() => !exhausted() && rows().length > 0) + const selectableCount = createMemo(() => filtered().length + (hasMore() ? 1 : 0)) + /** The highlighted session row (undefined on the load-more row / empty list). */ + const highlighted = createMemo(() => filtered()[sel()]) + + const move = (dir: 1 | -1) => { + const count = selectableCount() + if (count) setSel(s => (s + dir + count) % count) + } + + const cycleTab = (dir: 1 | -1) => { + const at = SESSION_TABS.findIndex(t => t.id === tab()) + const next = SESSION_TABS[(at + dir + SESSION_TABS.length) % SESSION_TABS.length] + if (next) setTab(next.id) + } + + // ── preview (Space → session.peek; refreshed on highlight change) ─────── + const [previewOpen, setPreviewOpen] = createSignal(false) + const [peekInfo, setPeekInfo] = createSignal() + const [peekState, setPeekState] = createSignal<'loading' | 'ready' | 'error'>('loading') + let peekSeq = 0 + let peekTimer: ReturnType | undefined + const cancelPendingPeek = () => { + if (peekTimer) clearTimeout(peekTimer) + peekTimer = undefined + } + onCleanup(cancelPendingPeek) + const issuePeek = (id: string) => { + const seq = ++peekSeq + setPeekState('loading') + props.ops + .peek(id) + .then(raw => { + if (seq !== peekSeq) return // stale — a newer row was highlighted + const decoded = decodeSessionPeek(raw) + if (Option.isNone(decoded)) { + setPeekInfo(undefined) + setPeekState('error') + } else { + setPeekInfo(decoded.value) + setPeekState('ready') + } + }) + .catch(() => { + if (seq === peekSeq) { + setPeekInfo(undefined) + setPeekState('error') + } + }) + } + // One reactive key drives ALL peek fetches: the highlighted id while the + // pane is open (memo dedupes by value). First open fires immediately; + // subsequent highlight moves debounce, cancelling the pending timer — + // holding ↓ settles into exactly one peek. + const peekKey = createMemo(() => (previewOpen() ? (highlighted()?.id ?? undefined) : undefined)) + createEffect( + on(peekKey, (id, prev) => { + cancelPendingPeek() + if (!id) return + if (prev === undefined) issuePeek(id) + else peekTimer = setTimeout(() => issuePeek(id), props.peekDebounceMs ?? PEEK_DEBOUNCE_MS) + }) + ) + const togglePreview = () => { + if (previewOpen()) { + cancelPendingPeek() + peekSeq++ // in-flight responses are now stale + setPreviewOpen(false) + setPeekInfo(undefined) + } else if (highlighted()) { + setPreviewOpen(true) + } + } + + // ── inline rename (Ctrl+R → session.title) ────────────────────────────── + const [renaming, setRenaming] = createSignal(false) + const startRename = () => { + if (highlighted()) setRenaming(true) + } + const endRename = () => { + setRenaming(false) + searchRef?.focus() + } + const commitRename = () => { + const row = highlighted() + const title = (renameRef?.value ?? '').trim() + endRename() + if (!row || !title || title === row.title) return + props.ops + .rename(row.id, title) + .then(() => { + setRows(prev => prev.map(r => (r.id === row.id ? { ...r, title } : r))) + setNote(`renamed → ${title}`) + }) + .catch(error => { + // session.title only reaches LIVE gateway sessions — old rows reject; + // surface it instead of silently dropping the edit. + setNote(`rename failed: ${error instanceof Error ? error.message : 'gateway rejected'}`) + }) + } + + // One Esc/Ctrl+C press can reach us TWICE (the keymap close layer + the + // global handler — the picker runs both so close works even when focus + // never landed). The guard makes the press act once, so cancelling a rename + // never ALSO closes the picker. + let lastEscape = 0 + const oncePerPress = (fn: () => void) => { + const now = Date.now() + if (now - lastEscape < 50) return + lastEscape = now + fn() + } + const closeRequest = () => oncePerPress(() => (renaming() ? endRename() : props.onClose())) + useCloseLayer( + () => rootRef, + () => closeRequest() + ) + + const activate = () => { + const row = highlighted() + if (row) return props.onResume(row.id) + // the load-more row: next offset = rows fetched so far (server ordering) + if (hasMore() && sel() === filtered().length && !loading()) fetchPage(rows().length) + } + + useKeyboard(key => { + const action = routeSessionPickerKey( + key.name, + { ctrl: key.ctrl, shift: key.shift }, + { queryEmpty: !query(), renaming: renaming() } + ) + if (action.kind === 'pass') return + // every routed chord is consumed BEFORE the focused input sees it (Space + // would insert ' ', Enter would submit, Ctrl+R/↑↓ are native edits). + key.preventDefault() + switch (action.kind) { + case 'close': + return closeRequest() + case 'cancel-rename': + return closeRequest() + case 'commit-rename': + return commitRename() + case 'resume': + return activate() + case 'move': + return move(action.dir) + case 'cycle-tab': + return cycleTab(action.dir) + case 'preview': + return togglePreview() + case 'rename': + return startRename() + } + }) + + // ── render ────────────────────────────────────────────────────────────── + const maxRows = () => (previewOpen() ? MAX_ROWS_PREVIEW : MAX_ROWS) + const win = createMemo(() => { + const items: PickerRow[] = filtered().map((item, index) => ({ index, item, kind: 'item' })) + if (hasMore()) items.push({ index: filtered().length, item: undefined, kind: 'item' }) + return visibleRows(items, sel(), maxRows()) + }) + const nowMs = Date.now() // stamped once at open — the overlay is short-lived + const title = () => `⟲ Resume session (${filtered().length} of ${rows().length}${exhausted() ? '' : '+'})` + const peekMeta = (p: SessionPeekDecoded): string => { + const s = p.session + const parts: string[] = [] + if (s?.model) parts.push(s.model) + if (s?.cwd) parts.push(tailTruncate(s.cwd, 40)) + if (typeof s?.cost_usd === 'number') parts.push(`$${s.cost_usd.toFixed(2)}`) + parts.push(`${p.total_messages ?? s?.message_count ?? 0} msgs`) + if (s?.last_active) parts.push(relativeTime(s.last_active, nowMs)) + return parts.join(' · ') + } + + return ( + (rootRef = el)} + style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }} + border + > + {/* title + search line (input keeps focus the whole time) */} + + + {title()} + + {' '} + {'⌕ '} + (searchRef = el)} + focused + onInput={setQuery} + onMouseDown={() => searchRef?.focus()} + placeholder="search title · preview · cwd · id" + placeholderColor={theme().color.muted} + textColor={theme().color.text} + cursorColor={theme().color.accent} + backgroundColor="transparent" + focusedBackgroundColor="transparent" + style={{ flexGrow: 1, minWidth: 0 }} + /> + + loading… + + + t.label)} active={SESSION_TABS.findIndex(t => t.id === tab())} /> + {/* inline rename line (Ctrl+R) — its input takes focus while open */} + + + rename: + { + renameRef = el + el.value = highlighted()?.title ?? '' + el.focus() + }} + textColor={theme().color.text} + cursorColor={theme().color.accent} + backgroundColor="transparent" + focusedBackgroundColor="transparent" + style={{ flexGrow: 1, minWidth: 0 }} + /> + + + 0}> + {` ↑ ${win().above} more`} + + + {row => + row.kind === 'item' && row.item ? ( + setSel(row.index)}> + + + {row.index === sel() ? '❯ ' : ' '} + + + {row.item.title || excerpt(row.item.preview) || row.item.id} + + + {` ${rowMeta(row.item, nowMs)}`} + + ) : ( + // the load-more pseudo-row (kind 'item' with no session behind it) + + + {row.kind === 'item' && row.index === sel() ? '❯ ' : ' '} + + + {loading() ? 'loading…' : `↓ load more (${rows().length} loaded)`} + + + ) + } + + + + {listError() ? ' (session list unavailable)' : query() ? ' (no matches)' : ' (no sessions on this tab)'} + + + + results truncated — narrow the search + + {/* preview pane (Space) — head/tail excerpts from session.peek */} + + + + loading preview… + + + preview unavailable + + + {p => ( + <> + {peekMeta(p())} + + {m => ( + + {`${m.role ?? '?'} › `} + {excerpt(m.content ?? '')} + + )} + + (p().head?.length ?? 0) + (p().tail?.length ?? 0)}> + {` ⋯ ${p().total_messages} messages ⋯`} + + + {m => ( + + {`${m.role ?? '?'} › `} + {excerpt(m.content ?? '')} + + )} + + + )} + + + + + {note()} + + + {renaming() + ? 'Enter save · Esc cancel rename' + : '↑↓ select · Enter resume · Tab scope · Space preview · Ctrl+R rename · Esc cancel'} + + + ) +} diff --git a/ui-opentui/src/view/overlays/sessionSwitcher.tsx b/ui-opentui/src/view/overlays/sessionSwitcher.tsx deleted file mode 100644 index 1315209dc1f..00000000000 --- a/ui-opentui/src/view/overlays/sessionSwitcher.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/** - * SessionSwitcher — pick a session to resume (spec §2b; Ink - * `activeSessionSwitcher.tsx`). A native ` { - if (option) props.onPick(String(option.value)) - }} - backgroundColor={theme().color.statusBg} - selectedBackgroundColor={theme().color.selectionBg} - textColor={theme().color.text} - selectedTextColor={theme().color.text} - descriptionColor={theme().color.muted} - style={{ height: Math.min(16, Math.max(2, options().length * 2)), marginTop: 1 }} - /> - ↑↓ select · Enter resume · Esc cancel - - ) -}