mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): resume picker — tabbed /sessions with peek preview (supersedes switcher)
This commit is contained in:
parent
4e69fdb3be
commit
a09fa9df42
13 changed files with 1710 additions and 181 deletions
55
ui-opentui/src/boundary/schema/SessionPeek.ts
Normal file
55
ui-opentui/src/boundary/schema/SessionPeek.ts
Normal 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)
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
302
ui-opentui/src/logic/sessionPicker.ts
Normal file
302
ui-opentui/src/logic/sessionPicker.ts
Normal 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 + source→tab 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
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
256
ui-opentui/src/test/sessionPicker.test.ts
Normal file
256
ui-opentui/src/test/sessionPicker.test.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/**
|
||||
* Resume-picker pure logic (docs/plans/opentui-resume-picker.md §B item 5):
|
||||
* source→tab 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()
|
||||
})
|
||||
})
|
||||
358
ui-opentui/src/test/sessionPickerView.test.tsx
Normal file
358
ui-opentui/src/test/sessionPickerView.test.tsx
Normal 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 tab’s 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
|
|
|
|||
456
ui-opentui/src/view/overlays/sessionPicker.tsx
Normal file
456
ui-opentui/src/view/overlays/sessionPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue