From 3fe7709b86e2343a31aad76e50dd65b651f51243 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 8 Jun 2026 16:00:39 +0000 Subject: [PATCH] =?UTF-8?q?feat(opentui-v2):=20Phase=205c=20=E2=80=94=20se?= =?UTF-8?q?ssion=20switcher=20overlay=20(list=20=E2=86=92=20pick=20?= =?UTF-8?q?=E2=86=92=20resume)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A first-class picker (spec §2b, Ink activeSessionSwitcher): /sessions (aliases /resume, /switch, /session) → session.list → a native of sessions (title / preview / message count), onSelect → onPick(id); Esc cancels. - store: switcher state + openSwitcher/closeSwitcher; SessionItem type. - logic/resume.ts: mapSessionList(session.list result) → SessionItem[]. - logic/slash.ts: /sessions|/resume|/switch|/session client commands + listSessions/openSwitcher on SlashContext. - entry: resumeInto extracted (shared by bootstrap + switcher); slashCtx wires listSessions (session.list → mapSessionList) + openSwitcher; onResume runs resumeInto via runFork. App input zone is now prompt → switcher → composer (overlays replace, not stack, so the composer remounts/refocuses on close). Verified: bun run check green (43 tests / 7 files) — slash /sessions → switcher, + a switcher frame test (rows render, composer replaced). LIVE tmux: /sessions listed real titled sessions w/ counts/previews; ↓+Enter resumed the picked one (hydrate_ms=8) → transcript hydrated incl. the ⚡terminal tool row; switcher closed, composer returned; /quit clean. 3 of 6 first-class overlays done (prompts, pager, switcher). Smoke P5c + matrix updated. --- ui-tui-opentui-v2/src/entry/main.tsx | 61 +++++++++++++------ ui-tui-opentui-v2/src/logic/resume.ts | 27 +++++++- ui-tui-opentui-v2/src/logic/slash.ts | 21 ++++++- ui-tui-opentui-v2/src/logic/store.ts | 25 +++++++- ui-tui-opentui-v2/src/test/render.test.tsx | 23 +++++++ ui-tui-opentui-v2/src/test/slash.test.ts | 19 +++++- ui-tui-opentui-v2/src/view/App.tsx | 22 ++++++- .../src/view/overlays/sessionSwitcher.tsx | 55 +++++++++++++++++ 8 files changed, 226 insertions(+), 27 deletions(-) create mode 100644 ui-tui-opentui-v2/src/view/overlays/sessionSwitcher.tsx diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index b10ac70a18f..e14284805ee 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -27,7 +27,7 @@ import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts' import { getLog } from '../boundary/log.ts' import { acquireRenderer } from '../boundary/renderer.ts' import { makeAppLayer } from '../boundary/runtime.ts' -import { mapResumeHistory } from '../logic/resume.ts' +import { mapResumeHistory, mapSessionList } from '../logic/resume.ts' import { dispatchSlash, type SlashContext } from '../logic/slash.ts' import { createSessionStore, type SessionStore } from '../logic/store.ts' import { App } from '../view/App.tsx' @@ -50,6 +50,28 @@ export interface TuiInput { const READY_POLL = Duration.millis(100) const READY_TIMEOUT_MS = 20_000 +/** + * Resume a session INTO the store: buffer live events across the `session.resume` + * RPC, then replace history + replay (gotcha §8 #5 tool rows handled by + * mapResumeHistory). Shared by the launch bootstrap and the session switcher. + * Timed (rpc_ms / hydrate_ms) for the resume profile. + */ +const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: string, cols: number) => + Effect.gen(function* () { + store.beginBuffer() + const t0 = Date.now() + const resumed = yield* gateway.request<{ messages?: unknown }>('session.resume', { cols, session_id: sid }) + const t1 = Date.now() + const snapshot = mapResumeHistory(resumed?.messages) + store.commitSnapshot(snapshot) + getLog().info('bootstrap', 'session resumed', { + count: snapshot.length, + hydrate_ms: Date.now() - t1, + rpc_ms: t1 - t0, + sid + }) + }) + /** * Live session bootstrap: wait for the unsolicited `gateway.ready` handshake, * then either RESUME a session (hydrate its transcript — incl. tool rows — via @@ -82,23 +104,7 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp log.warn('bootstrap', 'no session to resume', { resumeId: input.resumeId }) return } - // Buffer live events across the resume RPC, then replace history + replay. - store.beginBuffer() - const t0 = Date.now() - const resumed = yield* gateway.request<{ messages?: unknown }>('session.resume', { - cols: input.cols, - session_id: sid - }) - const t1 = Date.now() - const snapshot = mapResumeHistory(resumed?.messages) - store.commitSnapshot(snapshot) - // Hydration profile: rpc_ms = server load + transport; hydrate_ms = map + store write. - log.info('bootstrap', 'session resumed', { - count: snapshot.length, - hydrate_ms: Date.now() - t1, - rpc_ms: t1 - t0, - sid - }) + yield* resumeInto(gateway, store, sid, input.cols) } else { const created = yield* gateway.request<{ session_id?: string }>('session.create', { cols: input.cols }) sid = created?.session_id ?? gateway.sessionId() @@ -156,11 +162,13 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { const slashCtx: SlashContext = { clearTranscript: () => store.clearTranscript(), confirm: (message, onConfirm) => store.setConfirm(message, onConfirm), + listSessions: () => Effect.runPromise(gateway.request('session.list', {})).then(mapSessionList), logTail: () => getLog() .tail(200) .map(e => `${e.scope}: ${e.msg}`), openPager: (title, text) => store.openPager(title, text), + openSwitcher: sessions => store.openSwitcher(sessions), pushSystem: text => store.pushSystem(text), quit: () => { if (!renderer.isDestroyed) renderer.destroy() @@ -170,6 +178,15 @@ 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) @@ -199,7 +216,13 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { render( () => ( store.state.theme}> - gateway.sessionId()} /> + gateway.sessionId()} + /> ), renderer diff --git a/ui-tui-opentui-v2/src/logic/resume.ts b/ui-tui-opentui-v2/src/logic/resume.ts index 258d526e5ee..0543f4861a5 100644 --- a/ui-tui-opentui-v2/src/logic/resume.ts +++ b/ui-tui-opentui-v2/src/logic/resume.ts @@ -9,7 +9,7 @@ * a live one. Resumed assistant text is given a single text part so it renders * through the native markdown path. IDs are `r*` (distinct from live `p*`). */ -import type { Message, Part } from './store.ts' +import type { Message, Part, SessionItem } from './store.ts' function readStr(value: unknown, key: string): string | undefined { if (!value || typeof value !== 'object') return undefined @@ -17,6 +17,31 @@ function readStr(value: unknown, key: string): string | undefined { 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 `session.list` result into switcher rows (loose-typed read). */ +export function mapSessionList(result: unknown): SessionItem[] { + if (!result || typeof result !== 'object') return [] + const sessions = (result as { sessions?: unknown }).sessions + if (!Array.isArray(sessions)) return [] + const out: SessionItem[] = [] + for (const s of sessions) { + const id = readStr(s, 'id') + if (!id) continue + out.push({ + id, + messageCount: readNum(s, 'message_count'), + preview: readStr(s, 'preview') ?? '', + title: readStr(s, 'title') ?? '' + }) + } + return out +} + export function mapResumeHistory(history: unknown): Message[] { if (!Array.isArray(history)) return [] const out: Message[] = [] diff --git a/ui-tui-opentui-v2/src/logic/slash.ts b/ui-tui-opentui-v2/src/logic/slash.ts index 47233aa3f16..2283f5b72e2 100644 --- a/ui-tui-opentui-v2/src/logic/slash.ts +++ b/ui-tui-opentui-v2/src/logic/slash.ts @@ -9,9 +9,10 @@ * 2. `slash.exec {command, session_id}` → `{output, warning?}` → system line * 3. on reject → `command.dispatch {arg, name, session_id}` → typed action * (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn · - * prefill → notice). Pager routing for long output lands with Phase 5a; for - * now long output is shown as a (multi-line) system message. + * prefill → notice). Long output routes to the pager (Phase 5a). */ +import type { SessionItem } from './store.ts' + export interface ParsedSlash { name: string arg: string @@ -42,6 +43,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 } function readStr(value: unknown, key: string): string | undefined { @@ -61,6 +66,7 @@ function present(ctx: SlashContext, title: string, text: string): void { const CLIENT_HELP = [ '/help — list commands', + '/sessions, /resume — switch/resume a session', '/clear, /new — clear the transcript (confirm)', '/logs — recent engine log lines', '/quit, /exit — quit', @@ -69,10 +75,21 @@ 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.') +} + /** The TUI-only client commands (run in-process, never hit the gateway). */ const CLIENT: Record = { clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript), exit: (_arg, ctx) => ctx.quit(), + resume: openSwitcher, + session: openSwitcher, + sessions: openSwitcher, + switch: openSwitcher, help: async (_arg, ctx) => { // Prefer the live catalog; fall back to the client list if it's unavailable. try { diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 0865d47b8a8..073d5dffe8d 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -64,6 +64,14 @@ export interface PagerState { text: string } +/** One row in the session switcher (from `session.list`). */ +export interface SessionItem { + id: string + title: string + preview: string + messageCount: number +} + export interface StoreState { ready: boolean messages: Message[] @@ -72,6 +80,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 } const LRU_LIMIT = 1000 @@ -88,7 +98,8 @@ export function createSessionStore() { messages: [], theme: DEFAULT_THEME, prompt: undefined, - pager: undefined + pager: undefined, + switcher: undefined }) // Monotonic part id (stable `key` per part so a new tool part below a streaming @@ -192,6 +203,16 @@ export function createSessionStore() { setState('pager', undefined) } + /** Open the session switcher with the given session rows (/sessions, /resume). */ + function openSwitcher(sessions: SessionItem[]) { + setState('switcher', sessions) + } + + /** Close the session switcher. */ + function closeSwitcher() { + setState('switcher', undefined) + } + /** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */ function apply(event: GatewayEvent): void { if (buffering) { @@ -356,6 +377,8 @@ export function createSessionStore() { setConfirm, openPager, closePager, + openSwitcher, + closeSwitcher, hydrate, beginBuffer, commitSnapshot, diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx index d47843129f6..749196c64ca 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -125,4 +125,27 @@ describe('App render (Phase 1, themed)', () => { expect(frame).not.toContain('a previous message') // transcript replaced by the pager 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 () => { + 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 } + ]) + + const frame = await captureFrame( + () => ( + store.state.theme}> + + + ), + { until: 'Resume a session', width: 72, height: 18 } + ) + + expect(frame).toContain('Resume a session') // switcher 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 + }) }) diff --git a/ui-tui-opentui-v2/src/test/slash.test.ts b/ui-tui-opentui-v2/src/test/slash.test.ts index e81ed7ef291..c064632c7e5 100644 --- a/ui-tui-opentui-v2/src/test/slash.test.ts +++ b/ui-tui-opentui-v2/src/test/slash.test.ts @@ -5,6 +5,9 @@ import { describe, expect, test } from 'bun:test' import { dispatchSlash, parseSlash, type SlashContext } from '../logic/slash.ts' +import type { SessionItem } from '../logic/store.ts' + +const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }] describe('parseSlash', () => { test('splits name + arg; rejects non-slash / empty', () => { @@ -22,6 +25,7 @@ interface Probe { submitted: string[] confirmed: Array<{ message: string; onConfirm: () => void }> paged: Array<{ title: string; text: string }> + switched: SessionItem[][] quit: { value: boolean } cleared: { value: boolean } } @@ -32,13 +36,16 @@ function makeCtx(request: (method: string, params: Record) => P const submitted: string[] = [] const confirmed: Probe['confirmed'] = [] const paged: Probe['paged'] = [] + const switched: Probe['switched'] = [] const quit = { value: false } const cleared = { value: false } const ctx: SlashContext = { clearTranscript: () => (cleared.value = true), confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }), + listSessions: () => Promise.resolve(FAKE_SESSIONS), logTail: () => ['gateway: spawned', 'bootstrap: session created'], openPager: (title, text) => paged.push({ text, title }), + openSwitcher: sessions => switched.push(sessions), pushSystem: text => system.push(text), quit: () => (quit.value = true), request: (method, params) => { @@ -48,7 +55,7 @@ function makeCtx(request: (method: string, params: Record) => P sessionId: () => 'sid-1', submit: text => submitted.push(text) } - return { calls, cleared, confirmed, ctx, paged, quit, submitted, system } + return { calls, cleared, confirmed, ctx, paged, quit, submitted, switched, system } } describe('dispatchSlash — client commands', () => { @@ -75,6 +82,16 @@ describe('dispatchSlash — client commands', () => { expect(p.paged[0]?.text).toContain('session created') }) + test('/sessions (and /resume) open the switcher with session.list rows', async () => { + const p = makeCtx(async () => ({})) + await dispatchSlash('/sessions', p.ctx) + expect(p.switched).toHaveLength(1) + expect(p.switched[0]).toEqual(FAKE_SESSIONS) + const p2 = makeCtx(async () => ({})) + await dispatchSlash('/resume', p2.ctx) + expect(p2.switched).toHaveLength(1) + }) + test('/help renders the gateway catalog', async () => { const p = makeCtx(async method => method === 'commands.catalog' ? { pairs: [['/model', 'switch model']], canon: {} } : {} diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index 6cbefcf79cd..3ff1d6d35dd 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -20,6 +20,7 @@ import type { SessionStore } from '../logic/store.ts' import { Composer } from './composer.tsx' import { Header } from './header.tsx' import { Pager } from './overlays/pager.tsx' +import { SessionSwitcher } from './overlays/sessionSwitcher.tsx' import { PromptOverlay } from './prompts/promptOverlay.tsx' import { Transcript } from './transcript.tsx' @@ -27,19 +28,27 @@ export interface AppProps { readonly store: SessionStore readonly onSubmit?: (text: string) => void readonly onRespond?: (method: string, params: Record) => void + readonly onResume?: (sessionId: string) => void readonly sessionId?: () => string | undefined } const NOOP = () => {} const NOOP_RESPOND = () => {} +const NOOP_RESUME = () => {} const NO_SESSION = () => undefined export function App(props: AppProps) { const blocked = () => props.store.state.prompt !== undefined const pager = () => props.store.state.pager - // Defer the close so the key that closed the overlay (Esc/q) can't land in the - // freshly-remounted composer. + const switcher = () => props.store.state.switcher + // Defer the close so the key that closed an overlay (Esc/q/Enter) can't land in + // the freshly-remounted composer. const closePager = () => setTimeout(() => props.store.closePager(), 0) + const closeSwitcher = () => setTimeout(() => props.store.closeSwitcher(), 0) + const resume = (id: string) => { + ;(props.onResume ?? NOOP_RESUME)(id) + closeSwitcher() + } return ( @@ -49,7 +58,14 @@ export function App(props: AppProps) { fallback={ <> - }> + }> + {sessions => } + + } + > ` over `session.list` rows; + * Enter resumes the chosen session (the entry runs the same resume-hydrate path + * as launch), Esc/Ctrl+C closes. Replaces the composer while open. + */ +import { useKeyboard } from '@opentui/solid' +import { createMemo } from 'solid-js' + +import type { SessionItem } from '../../logic/store.ts' +import { useTheme } from '../theme.tsx' + +export function SessionSwitcher(props: { + sessions: SessionItem[] + onPick: (sessionId: string) => void + onClose: () => void +}) { + const theme = useTheme() + useKeyboard(key => { + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) props.onClose() + }) + + const options = createMemo(() => + props.sessions.map(s => ({ + description: `${s.messageCount} msgs${s.preview ? ` · ${s.preview.slice(0, 60)}` : ''}`, + name: s.title || s.preview.slice(0, 48) || s.id, + value: s.id + })) + ) + + return ( + + + ⟲ Resume a session + +