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
+
+
+ )
+}