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:
parent
abce50e34d
commit
3fe7709b86
@ -27,7 +27,7 @@ import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts'
|
|||||||
import { getLog } from '../boundary/log.ts'
|
import { getLog } from '../boundary/log.ts'
|
||||||
import { acquireRenderer } from '../boundary/renderer.ts'
|
import { acquireRenderer } from '../boundary/renderer.ts'
|
||||||
import { makeAppLayer } from '../boundary/runtime.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 { dispatchSlash, type SlashContext } from '../logic/slash.ts'
|
||||||
import { createSessionStore, type SessionStore } from '../logic/store.ts'
|
import { createSessionStore, type SessionStore } from '../logic/store.ts'
|
||||||
import { App } from '../view/App.tsx'
|
import { App } from '../view/App.tsx'
|
||||||
@ -50,6 +50,28 @@ export interface TuiInput {
|
|||||||
const READY_POLL = Duration.millis(100)
|
const READY_POLL = Duration.millis(100)
|
||||||
const READY_TIMEOUT_MS = 20_000
|
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,
|
* Live session bootstrap: wait for the unsolicited `gateway.ready` handshake,
|
||||||
* then either RESUME a session (hydrate its transcript — incl. tool rows — via
|
* 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 })
|
log.warn('bootstrap', 'no session to resume', { resumeId: input.resumeId })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Buffer live events across the resume RPC, then replace history + replay.
|
yield* resumeInto(gateway, store, sid, input.cols)
|
||||||
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
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
const created = yield* gateway.request<{ session_id?: string }>('session.create', { cols: input.cols })
|
const created = yield* gateway.request<{ session_id?: string }>('session.create', { cols: input.cols })
|
||||||
sid = created?.session_id ?? gateway.sessionId()
|
sid = created?.session_id ?? gateway.sessionId()
|
||||||
@ -156,11 +162,13 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||||||
const slashCtx: SlashContext = {
|
const slashCtx: SlashContext = {
|
||||||
clearTranscript: () => store.clearTranscript(),
|
clearTranscript: () => store.clearTranscript(),
|
||||||
confirm: (message, onConfirm) => store.setConfirm(message, onConfirm),
|
confirm: (message, onConfirm) => store.setConfirm(message, onConfirm),
|
||||||
|
listSessions: () => Effect.runPromise(gateway.request('session.list', {})).then(mapSessionList),
|
||||||
logTail: () =>
|
logTail: () =>
|
||||||
getLog()
|
getLog()
|
||||||
.tail(200)
|
.tail(200)
|
||||||
.map(e => `${e.scope}: ${e.msg}`),
|
.map(e => `${e.scope}: ${e.msg}`),
|
||||||
openPager: (title, text) => store.openPager(title, text),
|
openPager: (title, text) => store.openPager(title, text),
|
||||||
|
openSwitcher: sessions => store.openSwitcher(sessions),
|
||||||
pushSystem: text => store.pushSystem(text),
|
pushSystem: text => store.pushSystem(text),
|
||||||
quit: () => {
|
quit: () => {
|
||||||
if (!renderer.isDestroyed) renderer.destroy()
|
if (!renderer.isDestroyed) renderer.destroy()
|
||||||
@ -170,6 +178,15 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||||||
submit: submitPrompt
|
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.
|
// The composer's submit: route `/command` through the slash ladder, else a prompt.
|
||||||
const submit = (text: string) => {
|
const submit = (text: string) => {
|
||||||
if (text.startsWith('/')) void dispatchSlash(text, slashCtx)
|
if (text.startsWith('/')) void dispatchSlash(text, slashCtx)
|
||||||
@ -199,7 +216,13 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<ThemeProvider theme={() => store.state.theme}>
|
<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>
|
</ThemeProvider>
|
||||||
),
|
),
|
||||||
renderer
|
renderer
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
* a live one. Resumed assistant text is given a single text part so it renders
|
* 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*`).
|
* 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 {
|
function readStr(value: unknown, key: string): string | undefined {
|
||||||
if (!value || typeof value !== 'object') return 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
|
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[] {
|
export function mapResumeHistory(history: unknown): Message[] {
|
||||||
if (!Array.isArray(history)) return []
|
if (!Array.isArray(history)) return []
|
||||||
const out: Message[] = []
|
const out: Message[] = []
|
||||||
|
|||||||
@ -9,9 +9,10 @@
|
|||||||
* 2. `slash.exec {command, session_id}` → `{output, warning?}` → system line
|
* 2. `slash.exec {command, session_id}` → `{output, warning?}` → system line
|
||||||
* 3. on reject → `command.dispatch {arg, name, session_id}` → typed action
|
* 3. on reject → `command.dispatch {arg, name, session_id}` → typed action
|
||||||
* (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn ·
|
* (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn ·
|
||||||
* prefill → notice). Pager routing for long output lands with Phase 5a; for
|
* prefill → notice). Long output routes to the pager (Phase 5a).
|
||||||
* now long output is shown as a (multi-line) system message.
|
|
||||||
*/
|
*/
|
||||||
|
import type { SessionItem } from './store.ts'
|
||||||
|
|
||||||
export interface ParsedSlash {
|
export interface ParsedSlash {
|
||||||
name: string
|
name: string
|
||||||
arg: string
|
arg: string
|
||||||
@ -42,6 +43,10 @@ export interface SlashContext {
|
|||||||
readonly quit: () => void
|
readonly quit: () => void
|
||||||
/** Recent log lines for `/logs` (the ring buffer). */
|
/** Recent log lines for `/logs` (the ring buffer). */
|
||||||
readonly logTail: () => string[]
|
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 {
|
function readStr(value: unknown, key: string): string | undefined {
|
||||||
@ -61,6 +66,7 @@ function present(ctx: SlashContext, title: string, text: string): void {
|
|||||||
|
|
||||||
const CLIENT_HELP = [
|
const CLIENT_HELP = [
|
||||||
'/help — list commands',
|
'/help — list commands',
|
||||||
|
'/sessions, /resume — switch/resume a session',
|
||||||
'/clear, /new — clear the transcript (confirm)',
|
'/clear, /new — clear the transcript (confirm)',
|
||||||
'/logs — recent engine log lines',
|
'/logs — recent engine log lines',
|
||||||
'/quit, /exit — quit',
|
'/quit, /exit — quit',
|
||||||
@ -69,10 +75,21 @@ const CLIENT_HELP = [
|
|||||||
|
|
||||||
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise<void>
|
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). */
|
/** The TUI-only client commands (run in-process, never hit the gateway). */
|
||||||
const CLIENT: Record<string, ClientHandler> = {
|
const CLIENT: Record<string, ClientHandler> = {
|
||||||
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
|
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
|
||||||
exit: (_arg, ctx) => ctx.quit(),
|
exit: (_arg, ctx) => ctx.quit(),
|
||||||
|
resume: openSwitcher,
|
||||||
|
session: openSwitcher,
|
||||||
|
sessions: openSwitcher,
|
||||||
|
switch: openSwitcher,
|
||||||
help: async (_arg, ctx) => {
|
help: async (_arg, ctx) => {
|
||||||
// Prefer the live catalog; fall back to the client list if it's unavailable.
|
// Prefer the live catalog; fall back to the client list if it's unavailable.
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -64,6 +64,14 @@ export interface PagerState {
|
|||||||
text: string
|
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 {
|
export interface StoreState {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
@ -72,6 +80,8 @@ export interface StoreState {
|
|||||||
prompt: ActivePrompt | undefined
|
prompt: ActivePrompt | undefined
|
||||||
/** The open pager overlay (replaces the transcript while set); undefined when none. */
|
/** The open pager overlay (replaces the transcript while set); undefined when none. */
|
||||||
pager: PagerState | undefined
|
pager: PagerState | undefined
|
||||||
|
/** The open session switcher (replaces the composer while set); undefined when none. */
|
||||||
|
switcher: SessionItem[] | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const LRU_LIMIT = 1000
|
const LRU_LIMIT = 1000
|
||||||
@ -88,7 +98,8 @@ export function createSessionStore() {
|
|||||||
messages: [],
|
messages: [],
|
||||||
theme: DEFAULT_THEME,
|
theme: DEFAULT_THEME,
|
||||||
prompt: undefined,
|
prompt: undefined,
|
||||||
pager: undefined
|
pager: undefined,
|
||||||
|
switcher: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// Monotonic part id (stable `key` per part so a new tool part below a streaming
|
// 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)
|
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. */
|
/** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */
|
||||||
function apply(event: GatewayEvent): void {
|
function apply(event: GatewayEvent): void {
|
||||||
if (buffering) {
|
if (buffering) {
|
||||||
@ -356,6 +377,8 @@ export function createSessionStore() {
|
|||||||
setConfirm,
|
setConfirm,
|
||||||
openPager,
|
openPager,
|
||||||
closePager,
|
closePager,
|
||||||
|
openSwitcher,
|
||||||
|
closeSwitcher,
|
||||||
hydrate,
|
hydrate,
|
||||||
beginBuffer,
|
beginBuffer,
|
||||||
commitSnapshot,
|
commitSnapshot,
|
||||||
|
|||||||
@ -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('a previous message') // transcript replaced by the pager
|
||||||
expect(frame).not.toContain('Type your message') // composer hidden while the pager is open
|
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
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,6 +5,9 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
import { dispatchSlash, parseSlash, type SlashContext } from '../logic/slash.ts'
|
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', () => {
|
describe('parseSlash', () => {
|
||||||
test('splits name + arg; rejects non-slash / empty', () => {
|
test('splits name + arg; rejects non-slash / empty', () => {
|
||||||
@ -22,6 +25,7 @@ interface Probe {
|
|||||||
submitted: string[]
|
submitted: string[]
|
||||||
confirmed: Array<{ message: string; onConfirm: () => void }>
|
confirmed: Array<{ message: string; onConfirm: () => void }>
|
||||||
paged: Array<{ title: string; text: string }>
|
paged: Array<{ title: string; text: string }>
|
||||||
|
switched: SessionItem[][]
|
||||||
quit: { value: boolean }
|
quit: { value: boolean }
|
||||||
cleared: { value: boolean }
|
cleared: { value: boolean }
|
||||||
}
|
}
|
||||||
@ -32,13 +36,16 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||||||
const submitted: string[] = []
|
const submitted: string[] = []
|
||||||
const confirmed: Probe['confirmed'] = []
|
const confirmed: Probe['confirmed'] = []
|
||||||
const paged: Probe['paged'] = []
|
const paged: Probe['paged'] = []
|
||||||
|
const switched: Probe['switched'] = []
|
||||||
const quit = { value: false }
|
const quit = { value: false }
|
||||||
const cleared = { value: false }
|
const cleared = { value: false }
|
||||||
const ctx: SlashContext = {
|
const ctx: SlashContext = {
|
||||||
clearTranscript: () => (cleared.value = true),
|
clearTranscript: () => (cleared.value = true),
|
||||||
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
||||||
|
listSessions: () => Promise.resolve(FAKE_SESSIONS),
|
||||||
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
||||||
openPager: (title, text) => paged.push({ text, title }),
|
openPager: (title, text) => paged.push({ text, title }),
|
||||||
|
openSwitcher: sessions => switched.push(sessions),
|
||||||
pushSystem: text => system.push(text),
|
pushSystem: text => system.push(text),
|
||||||
quit: () => (quit.value = true),
|
quit: () => (quit.value = true),
|
||||||
request: (method, params) => {
|
request: (method, params) => {
|
||||||
@ -48,7 +55,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||||||
sessionId: () => 'sid-1',
|
sessionId: () => 'sid-1',
|
||||||
submit: text => submitted.push(text)
|
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', () => {
|
describe('dispatchSlash — client commands', () => {
|
||||||
@ -75,6 +82,16 @@ describe('dispatchSlash — client commands', () => {
|
|||||||
expect(p.paged[0]?.text).toContain('session created')
|
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 () => {
|
test('/help renders the gateway catalog', async () => {
|
||||||
const p = makeCtx(async method =>
|
const p = makeCtx(async method =>
|
||||||
method === 'commands.catalog' ? { pairs: [['/model', 'switch model']], canon: {} } : {}
|
method === 'commands.catalog' ? { pairs: [['/model', 'switch model']], canon: {} } : {}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import type { SessionStore } from '../logic/store.ts'
|
|||||||
import { Composer } from './composer.tsx'
|
import { Composer } from './composer.tsx'
|
||||||
import { Header } from './header.tsx'
|
import { Header } from './header.tsx'
|
||||||
import { Pager } from './overlays/pager.tsx'
|
import { Pager } from './overlays/pager.tsx'
|
||||||
|
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
|
||||||
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
||||||
import { Transcript } from './transcript.tsx'
|
import { Transcript } from './transcript.tsx'
|
||||||
|
|
||||||
@ -27,19 +28,27 @@ export interface AppProps {
|
|||||||
readonly store: SessionStore
|
readonly store: SessionStore
|
||||||
readonly onSubmit?: (text: string) => void
|
readonly onSubmit?: (text: string) => void
|
||||||
readonly onRespond?: (method: string, params: Record<string, unknown>) => void
|
readonly onRespond?: (method: string, params: Record<string, unknown>) => void
|
||||||
|
readonly onResume?: (sessionId: string) => void
|
||||||
readonly sessionId?: () => string | undefined
|
readonly sessionId?: () => string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOOP = () => {}
|
const NOOP = () => {}
|
||||||
const NOOP_RESPOND = () => {}
|
const NOOP_RESPOND = () => {}
|
||||||
|
const NOOP_RESUME = () => {}
|
||||||
const NO_SESSION = () => undefined
|
const NO_SESSION = () => undefined
|
||||||
|
|
||||||
export function App(props: AppProps) {
|
export function App(props: AppProps) {
|
||||||
const blocked = () => props.store.state.prompt !== undefined
|
const blocked = () => props.store.state.prompt !== undefined
|
||||||
const pager = () => props.store.state.pager
|
const pager = () => props.store.state.pager
|
||||||
// Defer the close so the key that closed the overlay (Esc/q) can't land in the
|
const switcher = () => props.store.state.switcher
|
||||||
// freshly-remounted composer.
|
// 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 closePager = () => setTimeout(() => props.store.closePager(), 0)
|
||||||
|
const closeSwitcher = () => setTimeout(() => props.store.closeSwitcher(), 0)
|
||||||
|
const resume = (id: string) => {
|
||||||
|
;(props.onResume ?? NOOP_RESUME)(id)
|
||||||
|
closeSwitcher()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: 'column', flexGrow: 1, padding: 1 }}>
|
<box style={{ flexDirection: 'column', flexGrow: 1, padding: 1 }}>
|
||||||
@ -49,7 +58,14 @@ export function App(props: AppProps) {
|
|||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<Transcript store={props.store} />
|
<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
|
<PromptOverlay
|
||||||
store={props.store}
|
store={props.store}
|
||||||
onRespond={props.onRespond ?? NOOP_RESPOND}
|
onRespond={props.onRespond ?? NOOP_RESPOND}
|
||||||
|
|||||||
55
ui-tui-opentui-v2/src/view/overlays/sessionSwitcher.tsx
Normal file
55
ui-tui-opentui-v2/src/view/overlays/sessionSwitcher.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user