From 33ee34b58ea0d46601e544b589133f48f8ae580f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:40:17 -0700 Subject: [PATCH] fix(tui): make resume picker full-screen --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 4 +- .../2026-07-21-tui-resume-command.zh.md | 4 +- docs/config-catalog.md | 6 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 2 +- packages/ui/tui/README.md | 4 +- packages/ui/tui/src/index.ts | 146 +++++++++++------- .../snapshots/resume-sessions.expected.txt | 112 ++++++-------- packages/ui/tui/tests/tui.spec.ts | 20 +-- 9 files changed, 156 insertions(+), 146 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index 62dc61c019..d470b61414 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 526c3775bcae1bae63fb37b097091f83cfc67afd -2026-07-21-tui-resume-command.zh.md: d333a5bb22057d3d035c22950a84f51e0ca0640d +2026-07-21-tui-resume-command.md: 86f62e16f5e2ee83e2ed36f0ed675ca2a1422c4b +2026-07-21-tui-resume-command.zh.md: 06e58f81445aaaf5299282714148194c1d2aacf4 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 526c3775bc..86f62e16f5 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -10,7 +10,7 @@ The original `/resume` printed shell commands. It did not let a keyboard user in ## Decision -`/resume` uses the TUI's existing interactive overlay seam. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. +`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. `session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process. @@ -36,4 +36,4 @@ After preflight, the TUI flushes the current session, confirms that its agent re ## Testing -TUI tests cover keyboard navigation, title/id search, Escape cancellation, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the visible selector frame. +TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index d333a5bb22..06e58f8144 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 +`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 `session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 @@ -36,4 +36,4 @@ Status: implemented ## Testing -TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。 +TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 040b15baf3..49a0a1510f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1609,10 +1609,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. */ @@ -1635,7 +1631,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:278`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 8c93a004c2..efbe11099f 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -270,7 +270,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [ { waitFor: 'scripted TUI ready.', send: '/resume\r' }, { waitFor: 'Resume selector design', send: 'Resume selector design' }, - { waitFor: 'Search: Resume selector design', send: '\r' }, + { waitFor: '⌕ Resume selector design', send: '\r' }, { waitFor: 'Preserve restored state', send: '/exit\r' }, ], }) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 8dc10f20f8..3dae79bf73 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑ `/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 `↑ | `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 | diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 372a0c9497..2f7aab15af 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -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 = 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 diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index d78aa6d31f..db54654115 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -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| -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| +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| " " diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c6426d4c67..127d5c8807 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -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>() @@ -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) })