feat(opentui-v2): Phase 5c — session switcher overlay (list → pick → resume)

A first-class picker (spec §2b, Ink activeSessionSwitcher): /sessions (aliases
/resume, /switch, /session) → session.list → a native <select> overlay; Enter
resumes the chosen session via the SAME resumeInto hydrate path as launch, so
tool rows + transcript hydrate correctly. Esc closes. Reuses Phase 4b resume.

- view/overlays/sessionSwitcher.tsx: <select> 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.
This commit is contained in:
alt-glitch 2026-06-08 16:00:39 +00:00
parent abce50e34d
commit 3fe7709b86
8 changed files with 226 additions and 27 deletions

View file

@ -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(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} onSubmit={submit} onRespond={respond} sessionId={() => gateway.sessionId()} />
<App
store={store}
onSubmit={submit}
onRespond={respond}
onResume={onResume}
sessionId={() => gateway.sessionId()}
/>
</ThemeProvider>
),
renderer

View file

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

View file

@ -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<SessionItem[]>
/** 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<void>
/** 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<string, ClientHandler> = {
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 {

View file

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

View file

@ -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(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ 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
})
})

View file

@ -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<string, unknown>) => 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<string, unknown>) => 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: {} } : {}

View file

@ -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<string, unknown>) => 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 (
<box style={{ flexDirection: 'column', flexGrow: 1, padding: 1 }}>
@ -49,7 +58,14 @@ export function App(props: AppProps) {
fallback={
<>
<Transcript store={props.store} />
<Show when={blocked()} fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
<Show
when={blocked()}
fallback={
<Show when={switcher()} fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
</Show>
}
>
<PromptOverlay
store={props.store}
onRespond={props.onRespond ?? NOOP_RESPOND}

View file

@ -0,0 +1,55 @@
/**
* SessionSwitcher pick a session to resume (spec §2b; Ink
* `activeSessionSwitcher.tsx`). A native `<select>` 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 (
<box
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.accent}>
<b> Resume a session</b>
</text>
<select
focused
options={options()}
onSelect={(_index, option) => {
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 }}
/>
<text fg={theme().color.muted}> select · Enter resume · Esc cancel</text>
</box>
)
}