fix(tui): make resume picker full-screen

This commit is contained in:
ZiyaZhang
2026-07-23 22:40:17 -07:00
parent d3b00bbdff
commit 33ee34b58e
9 changed files with 156 additions and 146 deletions

View File

@@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
`/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
`resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text.
@@ -49,8 +49,6 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
| `modelDialogWidth` | `72` | Model-selector width in columns |
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
| `resumeDialogWidth` | `88` | Resume-selector width in columns |
| `resumeDialogMaxHeight` | `24` | Resume-selector maximum rows |
| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query |
| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries |
| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion |

View File

@@ -205,10 +205,6 @@ export interface TuiConfig {
modelDialogWidth?: number
/** Model-selector maximum height in terminal rows. */
modelDialogMaxHeight?: number
/** Resume-selector width in terminal columns. */
resumeDialogWidth?: number
/** Resume-selector maximum height in terminal rows. */
resumeDialogMaxHeight?: number
/** Maximum fuzzy file candidates displayed for one `@` query. */
fileSearchMaxResults?: number
/** Maximum paths retained in one `@` workspace index. */
@@ -239,8 +235,6 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const resumeDialogWidthSchema = z.number().step(1).min(36).default(88)
const resumeDialogMaxHeightSchema = z.number().step(1).min(8).default(24)
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
@@ -260,8 +254,6 @@ const tuiConfigSchemaFields = {
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
resumeDialogWidth: resumeDialogWidthSchema,
resumeDialogMaxHeight: resumeDialogMaxHeightSchema,
fileSearchMaxResults: fileSearchMaxResultsSchema,
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
@@ -302,8 +294,6 @@ export const Config: z<Config> = z.object({
questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
resumeDialogWidth: tuiConfigSchemaFields.resumeDialogWidth,
resumeDialogMaxHeight: tuiConfigSchemaFields.resumeDialogMaxHeight,
fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
@@ -324,8 +314,6 @@ export interface ResolvedTuiConfig {
questionDialogMaxHeight: number
modelDialogWidth: number
modelDialogMaxHeight: number
resumeDialogWidth: number
resumeDialogMaxHeight: number
fileSearchMaxResults: number
fileSearchMaxEntries: number
fileSearchExcludedDirectories: string[]
@@ -370,8 +358,6 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 72,
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
resumeDialogWidth: config?.resumeDialogWidth ?? 88,
resumeDialogMaxHeight: config?.resumeDialogMaxHeight ?? 24,
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],
@@ -1346,9 +1332,9 @@ function summarizeResumeCandidate(
}
}
/** Searchable keyboard selector over detached, preflighted resume summaries. */
class ResumeDialog implements Component, Focusable {
private query = ''
/** Full-viewport keyboard selector over detached, preflighted resume summaries. */
class ResumePicker implements Component, Focusable {
private readonly search = new Input()
private selectedIndex = 0
private error = ''
focused = false
@@ -1356,87 +1342,133 @@ class ResumeDialog implements Component, Focusable {
constructor(
private readonly candidates: readonly ResumeCandidate[],
private readonly maxVisible: number,
private readonly workspaceLabel: string,
private readonly viewportRows: () => number,
private readonly palette: Palette,
private readonly done: (candidate: ResumeCandidate) => void,
private readonly cancel: () => void,
) {}
invalidate(): void {}
invalidate(): void {
this.search.invalidate()
}
private filtered(): ResumeCandidate[] {
const query = this.query.trim().toLocaleLowerCase()
const query = this.search.getValue().trim().toLocaleLowerCase()
if (query === '') return [...this.candidates]
return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query)
|| candidate.record.header.id.toLocaleLowerCase().includes(query))
}
handleInput(data: string): void {
this.invalidate()
const filtered = this.filtered()
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
if (matchesKey(data, Key.ctrl('c'))) {
this.cancel()
return
}
if (matchesKey(data, Key.up)) {
if (matchesKey(data, Key.escape)) {
if (this.search.getValue() === '') this.cancel()
else {
this.search.setValue('')
this.selectedIndex = 0
this.error = ''
}
} else if (matchesKey(data, Key.up)) {
this.selectedIndex = filtered.length === 0
? 0
: (this.selectedIndex + filtered.length - 1) % filtered.length
} else if (matchesKey(data, Key.down)) {
this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length
} else if (matchesKey(data, Key.pageUp)) {
this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible)
} else if (matchesKey(data, Key.pageDown)) {
this.selectedIndex = Math.min(
Math.max(0, filtered.length - 1),
this.selectedIndex + this.maxVisible,
)
} else if (matchesKey(data, Key.enter)) {
const selected = filtered[this.selectedIndex]
if (selected === undefined) this.error = 'No session matches this search.'
else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
else this.done(selected)
} else if (data === '\x7f' || data === '\b') {
this.query = Array.from(this.query).slice(0, -1).join('')
this.selectedIndex = 0
this.error = ''
} else if (!Array.from(data).some(character => character < ' ' || character === '\x7f')) {
this.query += data
this.selectedIndex = 0
this.error = ''
} else {
const previous = this.search.getValue()
this.search.focused = this.focused
this.search.handleInput(data)
if (this.search.getValue() !== previous) {
this.selectedIndex = 0
this.error = ''
}
}
this.invalidate()
}
render(width: number): string[] {
const innerWidth = Math.max(1, width - 4)
this.search.focused = this.focused
const height = Math.max(1, this.viewportRows())
const horizontalPadding = width >= 12 ? 2 : 0
const contentWidth = Math.max(1, width - horizontalPadding * 2)
const indent = ' '.repeat(horizontalPadding)
const filtered = this.filtered()
if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
const start = Math.max(0, Math.min(
this.selectedIndex - Math.floor(this.maxVisible / 2),
filtered.length - this.maxVisible,
))
const end = Math.min(filtered.length, start + this.maxVisible)
const body: string[] = [
this.query === ''
? `${this.palette.muted('Search:')} ${this.palette.dim('title or session id')}`
: this.palette.text(`Search: ${displayText(this.query)}`),
const selected = filtered[this.selectedIndex]
const position = selected === undefined ? 0 : this.selectedIndex + 1
const lines: string[] = [
'',
`${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`,
'',
]
const searchInnerWidth = Math.max(1, contentWidth - 4)
lines.push(`${indent}${this.palette.dim(`${'─'.repeat(Math.max(0, contentWidth - 2))}`)}`)
const searchContent = (this.search.render(searchInnerWidth)[0] ?? '').replace(/^> /u, ' ')
const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '')
lines.push(
`${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`,
`${indent}${this.palette.dim(`${'─'.repeat(Math.max(0, contentWidth - 2))}`)}`,
'',
`${indent}${this.palette.muted(displayText(this.workspaceLabel))}`,
'',
)
const candidateBudget = Math.max(1, Math.floor((height - 13) / 4))
const visibleCount = Math.min(this.maxVisible, candidateBudget)
const start = Math.max(0, Math.min(
this.selectedIndex - Math.floor(visibleCount / 2),
filtered.length - visibleCount,
))
const end = Math.min(filtered.length, start + visibleCount)
const push = (line: string): void => {
lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`)
}
for (let index = start; index < end; index += 1) {
const candidate = filtered[index] as ResumeCandidate
const selected = index === this.selectedIndex
const active = index === this.selectedIndex
const status = [
candidate.disabledReason === 'current session' ? 'current' : undefined,
candidate.record.live ? 'live' : undefined,
candidate.record.persisted ? 'persisted' : undefined,
].filter((value): value is string => value !== undefined).join(' · ')
const lead = `${selected ? '' : ' '} ${displayText(candidate.title)}`
body.push(selected ? this.palette.bold(this.palette.accent(lead)) : lead)
const lead = `${active ? '' : ' '} ${displayText(candidate.title)}`
push(active ? this.palette.bold(this.palette.accent(lead)) : lead)
const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
body.push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
body.push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
if (candidate.disabledReason !== undefined) {
body.push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
}
}
if (filtered.length === 0) body.push(this.palette.warning('No matching sessions.'))
if (filtered.length > this.maxVisible) body.push(this.palette.dim(`${this.selectedIndex + 1}/${filtered.length}`))
body.push('', this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc cancel'))
if (this.error !== '') body.push(this.palette.error(displayText(this.error)))
return renderDialog('Resume session', body.flatMap(line => wrapTextWithAnsi(line, innerWidth)), width, this.palette)
if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
if (this.error !== '') {
lines.push('')
push(this.palette.error(displayText(this.error)))
}
const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}`
while (lines.length < height - 2) lines.push('')
lines.push(footer, '')
return lines.slice(0, height)
}
}
@@ -2932,18 +2964,20 @@ export function createTuiChat(
|| a.record.header.id.localeCompare(b.record.header.id))
if (isDisposed() || scan !== resumeScan) return
const session = overlayManager.open({
create: () => new ResumeDialog(
create: host => new ResumePicker(
candidates,
resolved.maxResumeOptions,
runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd),
() => host.viewport.rows,
palette,
(candidate) => { void handoffResume(candidate, session) },
() => { void session.close() },
),
options: {
width: resolved.resumeDialogWidth,
maxHeight: resolved.resumeDialogMaxHeight,
anchor: 'center',
margin: 1,
width: '100%',
maxHeight: '100%',
anchor: 'top-left',
margin: 0,
},
})
resumeOverlay = session

View File

@@ -1,69 +1,51 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=0 viewportRow=31 bufferRow=31
cursor hidden column=6 viewportRow=4 bufferRow=4
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
7-8| <blank>
9| " ╭ Resume session ──────────────────────────────────────────────────────────────────────╮ "
style 2-89 fg=bright-blue
10| " │ Search: title or session id │ "
style 2-2 fg=bright-blue
style 4-10 fg=bright-black
style 12-30 dim
style 89-89 fg=bright-blue
11| " "
style 2-2 fg=bright-blue
style 89-89 fg=bright-blue
12| " Untitled session "
style 2-2 fg=bright-blue
style 4-21 fg=bright-blue bold
style 89-89 fg=bright-blue
13| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable │ "
style 2-2 fg=bright-blue
style 4-69 fg=bright-black
style 89-89 fg=bright-blue
14| " │ current · live · main-session "
style 2-2 fg=bright-blue
style 4-34 dim
style 89-89 fg=bright-blue
15| " │ unavailable: current session "
style 2-2 fg=bright-blue
style 4-33 fg=yellow
style 89-89 fg=bright-blue
16| " │ Resume selector design "
style 2-2 fg=bright-blue
style 89-89 fg=bright-blue
17| " │ 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro │ "
style 2-2 fg=bright-blue
style 4-76 fg=bright-black
style 89-89 fg=bright-blue
18| " │ persisted · earlier-session │ "
style 2-2 fg=bright-blue
style 4-32 dim
style 89-89 fg=bright-blue
19| " │ │ "
style 2-2 fg=bright-blue
style 89-89 fg=bright-blue
20| " │ Type to search • ↑/↓ navigate • Enter resume • Esc cancel │ "
style 2-2 fg=bright-blue
style 4-60 dim
style 89-89 fg=bright-blue
21| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
style 2-89 fg=bright-blue
22-31| <blank>
0| " "
1| " Resume session (1 of 2) "
style 2-24 fg=bright-blue bold
2| " "
3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ "
style 2-89 dim
4| " │ ⌕ │ "
style 2-2 dim
style 6-6 inverse
style 89-89 dim
5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
style 2-89 dim
6| " "
7| " /workspace/project "
style 2-19 fg=bright-black
8| " "
9| " Untitled session "
style 2-19 fg=bright-blue bold
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
style 2-67 fg=bright-black
11| " current · live · main-session "
style 2-32 dim
12| " unavailable: current session "
style 2-31 fg=yellow
13| " Resume selector design "
14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
style 2-74 fg=bright-black
15| " persisted · earlier-session "
style 2-30 dim
16| " "
17| " "
18| " "
19| " "
20| " "
21| " "
22| " "
23| " "
24| " "
25| " "
26| " "
27| " "
28| " "
29| " "
30| " Type to search ↑/↓ navigate Enter resume Esc clear/cancel "
style 2-70 dim
31| " "

View File

@@ -159,8 +159,6 @@ describe('TUI config', () => {
questionDialogMaxHeight: 20,
modelDialogWidth: 72,
modelDialogMaxHeight: 20,
resumeDialogWidth: 88,
resumeDialogMaxHeight: 24,
fileSearchMaxResults: 20,
fileSearchMaxEntries: 10_000,
fileSearchExcludedDirectories: ['.git', 'node_modules'],
@@ -179,8 +177,6 @@ describe('TUI config', () => {
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
resumeDialogWidth: 84,
resumeDialogMaxHeight: 22,
fileSearchMaxResults: 7,
fileSearchMaxEntries: 123,
fileSearchExcludedDirectories: ['.git', 'generated'],
@@ -198,8 +194,6 @@ describe('TUI config', () => {
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
resumeDialogWidth: 84,
resumeDialogMaxHeight: 22,
fileSearchMaxResults: 7,
fileSearchMaxEntries: 123,
fileSearchExcludedDirectories: ['.git', 'generated'],
@@ -269,7 +263,7 @@ describe('resume command and /resume', () => {
await dispose(result)
})
it('opens a newest-active-first searchable selector and Esc cancels without side effects', async () => {
it('opens a newest-active-first searchable selector and Esc clears before cancelling', async () => {
const older = header('older-session', 500, '/workspace')
const newer = header('newer-session', 2000, '/workspace')
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
@@ -296,7 +290,11 @@ describe('resume command and /resume', () => {
expect(output).not.toContain('foreign-session')
result.terminal.send('Older')
await tick()
expect(result.terminal.output).toContain('Search: Older')
expect(result.terminal.output).toContain(' Older')
result.terminal.send('\x1b')
await tick()
expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
.not.toContain('⌕ Older')
result.terminal.send('\x1b')
await tick()
expect(handoff).not.toHaveBeenCalled()
@@ -325,7 +323,9 @@ describe('resume command and /resume', () => {
result.terminal.send('\x7f')
result.terminal.send('\x7f')
await tick()
expect(result.terminal.output).toContain('Search: title or session id')
const cleared = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
expect(cleared).toContain('⌕ ')
expect(cleared).not.toContain('zz')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('current session')
@@ -349,7 +349,7 @@ describe('resume command and /resume', () => {
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('1/3')
expect(result.terminal.output).toContain('(1 of 3)')
await dispose(result)
})