mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
feat(opentui-v2): Phase 5c — session switcher overlay (list → pick → resume)
A first-class picker (spec §2b, Ink activeSessionSwitcher): /sessions (aliases
/resume, /switch, /session) → session.list → a native <select> overlay; Enter
resumes the chosen session via the SAME resumeInto hydrate path as launch, so
tool rows + transcript hydrate correctly. Esc closes. Reuses Phase 4b resume.
- view/overlays/sessionSwitcher.tsx: <select> of sessions (title / preview /
message count), onSelect → onPick(id); Esc cancels.
- store: switcher state + openSwitcher/closeSwitcher; SessionItem type.
- logic/resume.ts: mapSessionList(session.list result) → SessionItem[].
- logic/slash.ts: /sessions|/resume|/switch|/session client commands +
listSessions/openSwitcher on SlashContext.
- entry: resumeInto extracted (shared by bootstrap + switcher); slashCtx wires
listSessions (session.list → mapSessionList) + openSwitcher; onResume runs
resumeInto via runFork. App input zone is now prompt → switcher → composer
(overlays replace, not stack, so the composer remounts/refocuses on close).
Verified: bun run check green (43 tests / 7 files) — slash /sessions → switcher,
+ a switcher frame test (rows render, composer replaced). LIVE tmux: /sessions
listed real titled sessions w/ counts/previews; ↓+Enter resumed the picked one
(hydrate_ms=8) → transcript hydrated incl. the ⚡terminal tool row; switcher
closed, composer returned; /quit clean. 3 of 6 first-class overlays done
(prompts, pager, switcher). Smoke P5c + matrix updated.
This commit is contained in:
parent
abce50e34d
commit
3fe7709b86
8 changed files with 226 additions and 27 deletions
|
|
@ -27,7 +27,7 @@ import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts'
|
|||
import { getLog } from '../boundary/log.ts'
|
||||
import { acquireRenderer } from '../boundary/renderer.ts'
|
||||
import { makeAppLayer } from '../boundary/runtime.ts'
|
||||
import { mapResumeHistory } from '../logic/resume.ts'
|
||||
import { mapResumeHistory, mapSessionList } from '../logic/resume.ts'
|
||||
import { dispatchSlash, type SlashContext } from '../logic/slash.ts'
|
||||
import { createSessionStore, type SessionStore } from '../logic/store.ts'
|
||||
import { App } from '../view/App.tsx'
|
||||
|
|
@ -50,6 +50,28 @@ export interface TuiInput {
|
|||
const READY_POLL = Duration.millis(100)
|
||||
const READY_TIMEOUT_MS = 20_000
|
||||
|
||||
/**
|
||||
* Resume a session INTO the store: buffer live events across the `session.resume`
|
||||
* RPC, then replace history + replay (gotcha §8 #5 tool rows handled by
|
||||
* mapResumeHistory). Shared by the launch bootstrap and the session switcher.
|
||||
* Timed (rpc_ms / hydrate_ms) for the resume profile.
|
||||
*/
|
||||
const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: string, cols: number) =>
|
||||
Effect.gen(function* () {
|
||||
store.beginBuffer()
|
||||
const t0 = Date.now()
|
||||
const resumed = yield* gateway.request<{ messages?: unknown }>('session.resume', { cols, session_id: sid })
|
||||
const t1 = Date.now()
|
||||
const snapshot = mapResumeHistory(resumed?.messages)
|
||||
store.commitSnapshot(snapshot)
|
||||
getLog().info('bootstrap', 'session resumed', {
|
||||
count: snapshot.length,
|
||||
hydrate_ms: Date.now() - t1,
|
||||
rpc_ms: t1 - t0,
|
||||
sid
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Live session bootstrap: wait for the unsolicited `gateway.ready` handshake,
|
||||
* then either RESUME a session (hydrate its transcript — incl. tool rows — via
|
||||
|
|
@ -82,23 +104,7 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp
|
|||
log.warn('bootstrap', 'no session to resume', { resumeId: input.resumeId })
|
||||
return
|
||||
}
|
||||
// Buffer live events across the resume RPC, then replace history + replay.
|
||||
store.beginBuffer()
|
||||
const t0 = Date.now()
|
||||
const resumed = yield* gateway.request<{ messages?: unknown }>('session.resume', {
|
||||
cols: input.cols,
|
||||
session_id: sid
|
||||
})
|
||||
const t1 = Date.now()
|
||||
const snapshot = mapResumeHistory(resumed?.messages)
|
||||
store.commitSnapshot(snapshot)
|
||||
// Hydration profile: rpc_ms = server load + transport; hydrate_ms = map + store write.
|
||||
log.info('bootstrap', 'session resumed', {
|
||||
count: snapshot.length,
|
||||
hydrate_ms: Date.now() - t1,
|
||||
rpc_ms: t1 - t0,
|
||||
sid
|
||||
})
|
||||
yield* resumeInto(gateway, store, sid, input.cols)
|
||||
} else {
|
||||
const created = yield* gateway.request<{ session_id?: string }>('session.create', { cols: input.cols })
|
||||
sid = created?.session_id ?? gateway.sessionId()
|
||||
|
|
@ -156,11 +162,13 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||
const slashCtx: SlashContext = {
|
||||
clearTranscript: () => store.clearTranscript(),
|
||||
confirm: (message, onConfirm) => store.setConfirm(message, onConfirm),
|
||||
listSessions: () => Effect.runPromise(gateway.request('session.list', {})).then(mapSessionList),
|
||||
logTail: () =>
|
||||
getLog()
|
||||
.tail(200)
|
||||
.map(e => `${e.scope}: ${e.msg}`),
|
||||
openPager: (title, text) => store.openPager(title, text),
|
||||
openSwitcher: sessions => store.openSwitcher(sessions),
|
||||
pushSystem: text => store.pushSystem(text),
|
||||
quit: () => {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
|
|
@ -170,6 +178,15 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||
submit: submitPrompt
|
||||
}
|
||||
|
||||
// Resume a chosen session (session switcher pick) — same hydrate path as launch.
|
||||
const onResume = (resumeSid: string) => {
|
||||
Effect.runFork(
|
||||
resumeInto(gateway, store, resumeSid, input.cols).pipe(
|
||||
Effect.catchCause(cause => Effect.sync(() => getLog().warn('resume', 'failed', { cause: String(cause) })))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// The composer's submit: route `/command` through the slash ladder, else a prompt.
|
||||
const submit = (text: string) => {
|
||||
if (text.startsWith('/')) void dispatchSlash(text, slashCtx)
|
||||
|
|
@ -199,7 +216,13 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||
render(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} onSubmit={submit} onRespond={respond} sessionId={() => gateway.sessionId()} />
|
||||
<App
|
||||
store={store}
|
||||
onSubmit={submit}
|
||||
onRespond={respond}
|
||||
onResume={onResume}
|
||||
sessionId={() => gateway.sessionId()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
),
|
||||
renderer
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* a live one. Resumed assistant text is given a single text part so it renders
|
||||
* through the native markdown path. IDs are `r*` (distinct from live `p*`).
|
||||
*/
|
||||
import type { Message, Part } from './store.ts'
|
||||
import type { Message, Part, SessionItem } from './store.ts'
|
||||
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
|
|
@ -17,6 +17,31 @@ function readStr(value: unknown, key: string): string | undefined {
|
|||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
function readNum(value: unknown, key: string): number {
|
||||
if (!value || typeof value !== 'object') return 0
|
||||
const v = (value as { [k: string]: unknown })[key]
|
||||
return typeof v === 'number' ? v : 0
|
||||
}
|
||||
|
||||
/** Map a `session.list` result into switcher rows (loose-typed read). */
|
||||
export function mapSessionList(result: unknown): SessionItem[] {
|
||||
if (!result || typeof result !== 'object') return []
|
||||
const sessions = (result as { sessions?: unknown }).sessions
|
||||
if (!Array.isArray(sessions)) return []
|
||||
const out: SessionItem[] = []
|
||||
for (const s of sessions) {
|
||||
const id = readStr(s, 'id')
|
||||
if (!id) continue
|
||||
out.push({
|
||||
id,
|
||||
messageCount: readNum(s, 'message_count'),
|
||||
preview: readStr(s, 'preview') ?? '',
|
||||
title: readStr(s, 'title') ?? ''
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function mapResumeHistory(history: unknown): Message[] {
|
||||
if (!Array.isArray(history)) return []
|
||||
const out: Message[] = []
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@
|
|||
* 2. `slash.exec {command, session_id}` → `{output, warning?}` → system line
|
||||
* 3. on reject → `command.dispatch {arg, name, session_id}` → typed action
|
||||
* (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn ·
|
||||
* prefill → notice). Pager routing for long output lands with Phase 5a; for
|
||||
* now long output is shown as a (multi-line) system message.
|
||||
* prefill → notice). Long output routes to the pager (Phase 5a).
|
||||
*/
|
||||
import type { SessionItem } from './store.ts'
|
||||
|
||||
export interface ParsedSlash {
|
||||
name: string
|
||||
arg: string
|
||||
|
|
@ -42,6 +43,10 @@ export interface SlashContext {
|
|||
readonly quit: () => void
|
||||
/** Recent log lines for `/logs` (the ring buffer). */
|
||||
readonly logTail: () => string[]
|
||||
/** Fetch the resumable sessions (`session.list`) for the switcher. */
|
||||
readonly listSessions: () => Promise<SessionItem[]>
|
||||
/** Open the session switcher with the given rows. */
|
||||
readonly openSwitcher: (sessions: SessionItem[]) => void
|
||||
}
|
||||
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
|
|
@ -61,6 +66,7 @@ function present(ctx: SlashContext, title: string, text: string): void {
|
|||
|
||||
const CLIENT_HELP = [
|
||||
'/help — list commands',
|
||||
'/sessions, /resume — switch/resume a session',
|
||||
'/clear, /new — clear the transcript (confirm)',
|
||||
'/logs — recent engine log lines',
|
||||
'/quit, /exit — quit',
|
||||
|
|
@ -69,10 +75,21 @@ const CLIENT_HELP = [
|
|||
|
||||
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise<void>
|
||||
|
||||
/** Fetch sessions and open the switcher (shared by /sessions, /resume, /switch, /session). */
|
||||
const openSwitcher: ClientHandler = async (_arg, ctx) => {
|
||||
const sessions = await ctx.listSessions()
|
||||
if (sessions.length) ctx.openSwitcher(sessions)
|
||||
else ctx.pushSystem('No sessions to resume.')
|
||||
}
|
||||
|
||||
/** The TUI-only client commands (run in-process, never hit the gateway). */
|
||||
const CLIENT: Record<string, ClientHandler> = {
|
||||
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
|
||||
exit: (_arg, ctx) => ctx.quit(),
|
||||
resume: openSwitcher,
|
||||
session: openSwitcher,
|
||||
sessions: openSwitcher,
|
||||
switch: openSwitcher,
|
||||
help: async (_arg, ctx) => {
|
||||
// Prefer the live catalog; fall back to the client list if it's unavailable.
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,14 @@ export interface PagerState {
|
|||
text: string
|
||||
}
|
||||
|
||||
/** One row in the session switcher (from `session.list`). */
|
||||
export interface SessionItem {
|
||||
id: string
|
||||
title: string
|
||||
preview: string
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export interface StoreState {
|
||||
ready: boolean
|
||||
messages: Message[]
|
||||
|
|
@ -72,6 +80,8 @@ export interface StoreState {
|
|||
prompt: ActivePrompt | undefined
|
||||
/** The open pager overlay (replaces the transcript while set); undefined when none. */
|
||||
pager: PagerState | undefined
|
||||
/** The open session switcher (replaces the composer while set); undefined when none. */
|
||||
switcher: SessionItem[] | undefined
|
||||
}
|
||||
|
||||
const LRU_LIMIT = 1000
|
||||
|
|
@ -88,7 +98,8 @@ export function createSessionStore() {
|
|||
messages: [],
|
||||
theme: DEFAULT_THEME,
|
||||
prompt: undefined,
|
||||
pager: undefined
|
||||
pager: undefined,
|
||||
switcher: undefined
|
||||
})
|
||||
|
||||
// Monotonic part id (stable `key` per part so a new tool part below a streaming
|
||||
|
|
@ -192,6 +203,16 @@ export function createSessionStore() {
|
|||
setState('pager', undefined)
|
||||
}
|
||||
|
||||
/** Open the session switcher with the given session rows (/sessions, /resume). */
|
||||
function openSwitcher(sessions: SessionItem[]) {
|
||||
setState('switcher', sessions)
|
||||
}
|
||||
|
||||
/** Close the session switcher. */
|
||||
function closeSwitcher() {
|
||||
setState('switcher', undefined)
|
||||
}
|
||||
|
||||
/** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */
|
||||
function apply(event: GatewayEvent): void {
|
||||
if (buffering) {
|
||||
|
|
@ -356,6 +377,8 @@ export function createSessionStore() {
|
|||
setConfirm,
|
||||
openPager,
|
||||
closePager,
|
||||
openSwitcher,
|
||||
closeSwitcher,
|
||||
hydrate,
|
||||
beginBuffer,
|
||||
commitSnapshot,
|
||||
|
|
|
|||
|
|
@ -125,4 +125,27 @@ describe('App render (Phase 1, themed)', () => {
|
|||
expect(frame).not.toContain('a previous message') // transcript replaced by the pager
|
||||
expect(frame).not.toContain('Type your message') // composer hidden while the pager is open
|
||||
})
|
||||
|
||||
test('the session switcher renders session rows and replaces the composer', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.openSwitcher([
|
||||
{ id: 's1', title: 'First chat', preview: 'hi', messageCount: 5 },
|
||||
{ id: 's2', title: 'Second chat', preview: 'yo', messageCount: 12 }
|
||||
])
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'Resume a session', width: 72, height: 18 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('Resume a session') // switcher header
|
||||
expect(frame).toContain('First chat') // session row
|
||||
expect(frame).toContain('Second chat')
|
||||
expect(frame).not.toContain('Type your message') // composer hidden while switcher open
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { dispatchSlash, parseSlash, type SlashContext } from '../logic/slash.ts'
|
||||
import type { SessionItem } from '../logic/store.ts'
|
||||
|
||||
const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }]
|
||||
|
||||
describe('parseSlash', () => {
|
||||
test('splits name + arg; rejects non-slash / empty', () => {
|
||||
|
|
@ -22,6 +25,7 @@ interface Probe {
|
|||
submitted: string[]
|
||||
confirmed: Array<{ message: string; onConfirm: () => void }>
|
||||
paged: Array<{ title: string; text: string }>
|
||||
switched: SessionItem[][]
|
||||
quit: { value: boolean }
|
||||
cleared: { value: boolean }
|
||||
}
|
||||
|
|
@ -32,13 +36,16 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||
const submitted: string[] = []
|
||||
const confirmed: Probe['confirmed'] = []
|
||||
const paged: Probe['paged'] = []
|
||||
const switched: Probe['switched'] = []
|
||||
const quit = { value: false }
|
||||
const cleared = { value: false }
|
||||
const ctx: SlashContext = {
|
||||
clearTranscript: () => (cleared.value = true),
|
||||
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
||||
listSessions: () => Promise.resolve(FAKE_SESSIONS),
|
||||
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
||||
openPager: (title, text) => paged.push({ text, title }),
|
||||
openSwitcher: sessions => switched.push(sessions),
|
||||
pushSystem: text => system.push(text),
|
||||
quit: () => (quit.value = true),
|
||||
request: (method, params) => {
|
||||
|
|
@ -48,7 +55,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||
sessionId: () => 'sid-1',
|
||||
submit: text => submitted.push(text)
|
||||
}
|
||||
return { calls, cleared, confirmed, ctx, paged, quit, submitted, system }
|
||||
return { calls, cleared, confirmed, ctx, paged, quit, submitted, switched, system }
|
||||
}
|
||||
|
||||
describe('dispatchSlash — client commands', () => {
|
||||
|
|
@ -75,6 +82,16 @@ describe('dispatchSlash — client commands', () => {
|
|||
expect(p.paged[0]?.text).toContain('session created')
|
||||
})
|
||||
|
||||
test('/sessions (and /resume) open the switcher with session.list rows', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/sessions', p.ctx)
|
||||
expect(p.switched).toHaveLength(1)
|
||||
expect(p.switched[0]).toEqual(FAKE_SESSIONS)
|
||||
const p2 = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/resume', p2.ctx)
|
||||
expect(p2.switched).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('/help renders the gateway catalog', async () => {
|
||||
const p = makeCtx(async method =>
|
||||
method === 'commands.catalog' ? { pairs: [['/model', 'switch model']], canon: {} } : {}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type { SessionStore } from '../logic/store.ts'
|
|||
import { Composer } from './composer.tsx'
|
||||
import { Header } from './header.tsx'
|
||||
import { Pager } from './overlays/pager.tsx'
|
||||
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
|
||||
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
||||
import { Transcript } from './transcript.tsx'
|
||||
|
||||
|
|
@ -27,19 +28,27 @@ export interface AppProps {
|
|||
readonly store: SessionStore
|
||||
readonly onSubmit?: (text: string) => void
|
||||
readonly onRespond?: (method: string, params: Record<string, unknown>) => void
|
||||
readonly onResume?: (sessionId: string) => void
|
||||
readonly sessionId?: () => string | undefined
|
||||
}
|
||||
|
||||
const NOOP = () => {}
|
||||
const NOOP_RESPOND = () => {}
|
||||
const NOOP_RESUME = () => {}
|
||||
const NO_SESSION = () => undefined
|
||||
|
||||
export function App(props: AppProps) {
|
||||
const blocked = () => props.store.state.prompt !== undefined
|
||||
const pager = () => props.store.state.pager
|
||||
// Defer the close so the key that closed the overlay (Esc/q) can't land in the
|
||||
// freshly-remounted composer.
|
||||
const switcher = () => props.store.state.switcher
|
||||
// Defer the close so the key that closed an overlay (Esc/q/Enter) can't land in
|
||||
// the freshly-remounted composer.
|
||||
const closePager = () => setTimeout(() => props.store.closePager(), 0)
|
||||
const closeSwitcher = () => setTimeout(() => props.store.closeSwitcher(), 0)
|
||||
const resume = (id: string) => {
|
||||
;(props.onResume ?? NOOP_RESUME)(id)
|
||||
closeSwitcher()
|
||||
}
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: 'column', flexGrow: 1, padding: 1 }}>
|
||||
|
|
@ -49,7 +58,14 @@ export function App(props: AppProps) {
|
|||
fallback={
|
||||
<>
|
||||
<Transcript store={props.store} />
|
||||
<Show when={blocked()} fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
|
||||
<Show
|
||||
when={blocked()}
|
||||
fallback={
|
||||
<Show when={switcher()} fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
|
||||
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<PromptOverlay
|
||||
store={props.store}
|
||||
onRespond={props.onRespond ?? NOOP_RESPOND}
|
||||
|
|
|
|||
55
ui-tui-opentui-v2/src/view/overlays/sessionSwitcher.tsx
Normal file
55
ui-tui-opentui-v2/src/view/overlays/sessionSwitcher.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* SessionSwitcher — pick a session to resume (spec §2b; Ink
|
||||
* `activeSessionSwitcher.tsx`). A native `<select>` over `session.list` rows;
|
||||
* Enter resumes the chosen session (the entry runs the same resume-hydrate path
|
||||
* as launch), Esc/Ctrl+C closes. Replaces the composer while open.
|
||||
*/
|
||||
import { useKeyboard } from '@opentui/solid'
|
||||
import { createMemo } from 'solid-js'
|
||||
|
||||
import type { SessionItem } from '../../logic/store.ts'
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
export function SessionSwitcher(props: {
|
||||
sessions: SessionItem[]
|
||||
onPick: (sessionId: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
useKeyboard(key => {
|
||||
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) props.onClose()
|
||||
})
|
||||
|
||||
const options = createMemo(() =>
|
||||
props.sessions.map(s => ({
|
||||
description: `${s.messageCount} msgs${s.preview ? ` · ${s.preview.slice(0, 60)}` : ''}`,
|
||||
name: s.title || s.preview.slice(0, 48) || s.id,
|
||||
value: s.id
|
||||
}))
|
||||
)
|
||||
|
||||
return (
|
||||
<box
|
||||
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
|
||||
border
|
||||
>
|
||||
<text fg={theme().color.accent}>
|
||||
<b>⟲ Resume a session</b>
|
||||
</text>
|
||||
<select
|
||||
focused
|
||||
options={options()}
|
||||
onSelect={(_index, option) => {
|
||||
if (option) props.onPick(String(option.value))
|
||||
}}
|
||||
backgroundColor={theme().color.statusBg}
|
||||
selectedBackgroundColor={theme().color.selectionBg}
|
||||
textColor={theme().color.text}
|
||||
selectedTextColor={theme().color.text}
|
||||
descriptionColor={theme().color.muted}
|
||||
style={{ height: Math.min(16, Math.max(2, options().length * 2)), marginTop: 1 }}
|
||||
/>
|
||||
<text fg={theme().color.muted}>↑↓ select · Enter resume · Esc cancel</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue