opentui(v6): resume picker — tabbed /sessions with peek preview (supersedes switcher)

This commit is contained in:
alt-glitch 2026-06-10 23:46:07 +05:30
parent 4e69fdb3be
commit a09fa9df42
13 changed files with 1710 additions and 181 deletions

View file

@ -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<SessionPeekDecoded>`. */
export const decodeSessionPeek = Schema.decodeUnknownOption(SessionPeekSchema)

View file

@ -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<unknown>('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<unknown>('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<string, unknown> }>('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<string, unknown> }>(
'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<unknown>('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<unknown>('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 <id>` 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}

View file

@ -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 + sourcetab 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 <tab>` arg parsing and the `/resume <id|name>` 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<string, unknown> {
const base: Record<string, unknown> = { 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 <tab>` 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 <id|name>` 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
}

View file

@ -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<SessionItem[]>
/** 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 <id|name>` — 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<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.')
/** `/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 <id|name>` 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<string, ClientHandler> = {
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) => {

View file

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

View file

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

View file

@ -0,0 +1,256 @@
/**
* Resume-picker pure logic (docs/plans/opentui-resume-picker.md §B item 5):
* sourcetab 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 <tab>` arg parser and the `/resume <id|name>` 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>): 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()
})
})

View file

@ -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<string, unknown>[]
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<Harness> {
const sessions = options.sessions ?? SESSIONS
const listCalls: Record<string, unknown>[] = []
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(
() => (
<ThemeProvider theme={() => DEFAULT_THEME}>
<SessionPicker
ops={ops}
initialTab={options.initialTab ?? 'recent'}
peekDebounceMs={20}
onResume={id => resumed.push(id)}
onClose={() => (closed.value = true)}
/>
</ThemeProvider>
),
// 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 tabs 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()
}
})
})

View file

@ -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<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => 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 <id|name> 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 () => {

View file

@ -52,14 +52,14 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => 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) => {

View file

@ -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 <scrollbox>; §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 `<Switch>`), 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<string, unknown>) => 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}
/>
</Match>
<Match when={switcher()}>
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
<Match when={sessionPicker()}>
{sp => (
<SessionPicker
ops={props.sessionOps ?? NOOP_OPS}
initialTab={sp().tab}
onResume={resume}
onClose={closeSessionPicker}
/>
)}
</Match>
<Match when={picker()}>
{p => (

View file

@ -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 `<input>` (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<string, unknown>) => Promise<unknown>
/** Raw `session.peek` result for a session id. */
readonly peek: (sessionId: string) => Promise<unknown>
/** Rename via `session.title` (rejects when the gateway can't — surfaced). */
readonly rename: (sessionId: string, title: string) => Promise<void>
}
/** 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<SessionTabId>(props.initialTab ?? 'recent')
const [query, setQuery] = createSignal('')
const [rows, setRows] = createSignal<SessionRow[]>([])
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<string | undefined>()
// ── 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<SessionPeekDecoded | undefined>()
const [peekState, setPeekState] = createSignal<'loading' | 'ready' | 'error'>('loading')
let peekSeq = 0
let peekTimer: ReturnType<typeof setTimeout> | 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<SessionRow | undefined>[] = 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 (
<box
ref={el => (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) */}
<box style={{ flexDirection: 'row' }}>
<text fg={theme().color.accent}>
<b>{title()}</b>
</text>
<text fg={theme().color.label}>{' '}</text>
<text fg={theme().color.prompt}>{'⌕ '}</text>
<input
ref={el => (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 }}
/>
<Show when={loading()}>
<text fg={theme().color.muted}>loading</text>
</Show>
</box>
<TabChips labels={SESSION_TABS.map(t => t.label)} active={SESSION_TABS.findIndex(t => t.id === tab())} />
{/* inline rename line (Ctrl+R) — its input takes focus while open */}
<Show when={renaming()}>
<box style={{ flexDirection: 'row' }}>
<text fg={theme().color.accent}>rename: </text>
<input
ref={el => {
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 }}
/>
</box>
</Show>
<Show when={win().above > 0}>
<text fg={theme().color.muted}>{`${win().above} more`}</text>
</Show>
<For each={win().rows}>
{row =>
row.kind === 'item' && row.item ? (
<box style={{ flexDirection: 'column' }} onMouseDown={() => setSel(row.index)}>
<text bg={row.index === sel() ? theme().color.selectionBg : 'transparent'}>
<span style={{ fg: row.index === sel() ? theme().color.text : theme().color.muted }}>
{row.index === sel() ? ' ' : ' '}
</span>
<span style={{ fg: theme().color.text }}>
{row.item.title || excerpt(row.item.preview) || row.item.id}
</span>
</text>
<text fg={theme().color.muted}>{` ${rowMeta(row.item, nowMs)}`}</text>
</box>
) : (
// the load-more pseudo-row (kind 'item' with no session behind it)
<text bg={row.kind === 'item' && row.index === sel() ? theme().color.selectionBg : 'transparent'}>
<span style={{ fg: theme().color.muted }}>
{row.kind === 'item' && row.index === sel() ? ' ' : ' '}
</span>
<span style={{ fg: theme().color.muted }}>
{loading() ? 'loading…' : `↓ load more (${rows().length} loaded)`}
</span>
</text>
)
}
</For>
<Show when={!loading() && filtered().length === 0}>
<text fg={theme().color.muted}>
{listError() ? ' (session list unavailable)' : query() ? ' (no matches)' : ' (no sessions on this tab)'}
</text>
</Show>
<Show when={truncated()}>
<text fg={theme().color.warn}> results truncated narrow the search</text>
</Show>
{/* preview pane (Space) — head/tail excerpts from session.peek */}
<Show when={previewOpen()}>
<box
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0 }}
border
title="preview (Space)"
>
<Show when={peekState() === 'loading'}>
<text fg={theme().color.muted}>loading preview</text>
</Show>
<Show when={peekState() === 'error'}>
<text fg={theme().color.muted}>preview unavailable</text>
</Show>
<Show when={peekState() === 'ready' && peekInfo()}>
{p => (
<>
<text fg={theme().color.label}>{peekMeta(p())}</text>
<For each={p().head ?? []}>
{m => (
<text>
<span style={{ fg: theme().color.accent }}>{`${m.role ?? '?'} `}</span>
<span style={{ fg: theme().color.text }}>{excerpt(m.content ?? '')}</span>
</text>
)}
</For>
<Show when={(p().total_messages ?? 0) > (p().head?.length ?? 0) + (p().tail?.length ?? 0)}>
<text fg={theme().color.muted}>{`${p().total_messages} messages ⋯`}</text>
</Show>
<For each={p().tail ?? []}>
{m => (
<text>
<span style={{ fg: theme().color.accent }}>{`${m.role ?? '?'} `}</span>
<span style={{ fg: theme().color.text }}>{excerpt(m.content ?? '')}</span>
</text>
)}
</For>
</>
)}
</Show>
</box>
</Show>
<Show when={note()}>
<text fg={theme().color.muted}>{note()}</text>
</Show>
<text fg={theme().color.muted}>
{renaming()
? 'Enter save · Esc cancel rename'
: '↑↓ select · Enter resume · Tab scope · Space preview · Ctrl+R rename · Esc cancel'}
</text>
</box>
)
}

View file

@ -1,60 +0,0 @@
/**
* 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 type { BoxRenderable } from '@opentui/core'
import { createMemo } from 'solid-js'
import type { SessionItem } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
export function SessionSwitcher(props: {
sessions: SessionItem[]
onPick: (sessionId: string) => void
onClose: () => void
}) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
// Native select handles ↑↓/Enter; the keymap owns Esc/Ctrl+C close.
useCloseLayer(
() => rootRef,
() => 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
ref={el => (rootRef = el)}
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>
)
}