Merge pull request #1063 from deepseek-harness/perf/tui-resume-scan

perf(tui): open the /resume selector from one batch session projection
This commit is contained in:
Turtle
2026-08-03 22:17:59 +08:00
committed by GitHub
21 changed files with 833 additions and 237 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md
2026-07-31-resume-selector-batch-projection.md: 39146527f13b20813bb6f5d5f1349ecab5724662
2026-07-31-resume-selector-batch-projection.zh.md: 10333ea7cc5e7f2051c37f3374c5dc061bd0586e
@@ -0,0 +1,35 @@
# Agent Note: Resume selector folds titles only
Status: implemented
English | [中文](2026-07-31-resume-selector-batch-projection.zh.md)
## Problem
Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per listed session under an unbounded `Promise.all`. Each call re-listed the whole persistence store inside `SessionCorpus.load()` (O(N²) listings), read and decompressed the complete log, replay-validated every event through the `Session` constructor, and deep-cloned the header and events up to three times — all to derive one selector row's title, last-activity time, last `turn/end` label, provider/model route, and goal phase. On a real store (185 sessions, 87 MB compressed, ~353k events) the selector took tens of seconds to open, and the cost grew with total log size rather than session count.
## Decision
Selector rows fold nothing but titles, and everything else a row shows comes from metadata:
- Titles come from the projection system: `session-title` already registers a `title` unit, so a live row reads the registry snapshot, a persisted row reads the durable checkpoint row (`sessionProjectionCache.cachedSnapshot`, zero I/O), and only a row without a usable checkpoint pays a `coldSnapshot` — checkpoint plus a `readFrom` tail, written back so the next scan is zero-I/O. Cold reads are bounded by the TUI `resumeScanConcurrency` config. A composition without the cache falls back to one bounded `readTitleSnapshots` batch over the logs; either path isolates a per-row failure into the disabled "Unreadable session" fallback.
- The activity timestamp never reads a log: a live session uses its last in-memory event time; a persisted session stats the artifact named by the optional `sessionPersistence.locate()` (mtime), falling back to the header's creation time when the backend locates no per-session artifact (SQLite) or the stat fails. Any append moves the mtime, so a mere pickup boundary now floats a browsed session up — accepted as the price of a metadata-only timestamp.
- The last-turn label, provider/model route, and goal phase columns are gone from rows. Route availability is now enforced by the Enter-time preflight, which fully reads and replay-validates the one chosen log through `readSession` before handoff.
The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame, Enter reports that sessions are still loading, and Escape cancels. Closing the overlay aborts the scan through the `AbortSignal` the query methods accept; a signal-ignoring backend's late settlement is dropped by a staleness check. The finished scan swaps rows in through `setCandidates` (clearing a stale still-loading error) without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; one catch spans listing, titles, and mtimes, so any scan failure closes the overlay and reports a notice rather than stranding the loading placeholder.
No session-query or session-persistence surface changed. The shipped TUI composition gains the projection registry, storage, and projection-cache rows (mirroring the web overlay over the same `storages` root, so checkpoints written by either surface serve both); the first scan over a pre-existing store still reads each log once to seed checkpoints, and every later scan is metadata-only.
## Alternatives considered
**Keep per-row route/turn/goal columns via a generic batch projection (`projectSessions`).** Implemented first, then rejected: it still decompressed and parsed every log on every `/resume`, so browsing cost stayed O(total log bytes), and it grew the session-query public API for one consumer. The public seam was reverted; `readTitleSnapshots` keeps using the internal `projectMany` unchanged.
**Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominated on large logs. The redundant pre-listing in `load()` remains a candidate cleanup with error-semantics implications.
**Surface a last-modified time through `listSnapshots`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, both backends, and the query record shape for what the TUI can already derive from `locate()` plus one stat. Reintroduce if a second consumer needs metadata activity times.
**A bespoke persisted title index or TUI-local title cache.** Rejected: the session-projection cache already is the owned durable checkpoint system with an invalidation contract (`stateVersion`, identity binding, shrunk-log anchoring); mounting it beats adding a parallel cache.
## Consequences
Opening `/resume` performs one listing, one stat per persisted row, and per-row title reads that touch only checkpoint rows and log tails once checkpoints exist — O(session count) metadata instead of O(total log bytes); the fallback path without the cache remains one bounded title pass. Rows show title, timestamp, status, and id only; route problems surface as an Enter-time preflight error instead of a disabled row, and a session that fails replay is caught by preflight rather than the listing. Browsed-then-abandoned sessions float up on their pickup mtime. Fake `sessionQuery` services in TUI tests provide `readTitleSnapshots` alongside `listSessions`/`readSession`, and the test harness forwards an optional `locate`. Because the picker takes focus immediately, starting a second scan requires dismissing the current overlay first — a second `/resume` typed during a scan lands in the search field, which is the intended input capture.
@@ -0,0 +1,35 @@
# Agent Note: 恢复选择器只折叠标题
Status: implemented
[English](2026-07-31-resume-selector-batch-projection.md) | 中文
## Problem
打开 TUI `/resume` 选择器时,会在一个无界 `Promise.all` 中对每个列出的会话调用一次 `sessionQuery.readSession()`。每次调用都会在 `SessionCorpus.load()` 内部重新列出整个持久化存储(O(N²) 次列表查询)、读取并解压完整日志、通过 `Session` 构造函数对每个事件做回放验证,并将 header 和事件深克隆多达三次——而这一切只为推导一行选择器条目的标题、最近活动时间、最后一个 `turn/end` 标签、提供方/模型路由和目标阶段。在真实存储上(185 个会话、压缩后 87 MB、约 35.3 万个事件),选择器需要数十秒才能打开,且开销随日志总大小而非会话数量增长。
## Decision
选择器行除标题外不折叠任何内容,行内其余信息全部来自元数据:
- 标题来自投影系统:`session-title` 已注册 `title` 投影单元,因此实时行读取注册表快照,持久化行读取持久 checkpoint 行(`sessionProjectionCache.cachedSnapshot`,零 I/O),只有没有可用 checkpoint 的行才付出一次 `coldSnapshot`——checkpoint 加 `readFrom` 尾部折叠,并写回使下次扫描零 I/O。冷读取受 TUI `resumeScanConcurrency` 配置约束。未挂载缓存的组合回退到一次对日志的有界 `readTitleSnapshots` 批量读取;两条路径都把单行失败隔离为禁用的"Unreadable session"回退。
- 活动时间戳从不读取日志:实时会话取内存中最后一个事件的时间;持久化会话对可选 `sessionPersistence.locate()` 命名的产物做 stat(mtime),当后端定位不到按会话的产物(SQLite)或 stat 失败时回退到 header 的创建时间。任何追加都会移动 mtime,因此仅仅一次 pickup 边界也会让浏览过的会话上浮——这是元数据时间戳的代价,予以接受。
- 行内不再有最后轮次标签、提供方/模型路由和目标阶段列。路由可用性改由 Enter 时的预检强制:预检通过 `readSession` 完整读取并回放验证选中的那一份日志后才移交。
选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入,Enter 提示会话仍在加载,Escape 取消。关闭 overlay 会通过查询方法接受的 `AbortSignal` 中止扫描;忽略信号的后端的迟到结算由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询、标题与 mtime 共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告通知,而不会让加载占位符悬置。
session-query 与 session-persistence 的任何表面都未改变。随附的 TUI 组合新增投影注册表、storage 与投影缓存行(镜像 web overlay,共用同一 `storages` 根,因此任一表面写下的 checkpoint 都服务两者);对既有存储的首次扫描仍会各读取一次日志以播种 checkpoint,之后的每次扫描都只读元数据。
## Alternatives considered
**通过通用批量投影(`projectSessions`)保留每行的路由/轮次/目标列。** 先实现后否决:它仍在每次 `/resume` 时解压并解析全部日志,浏览开销依旧是 O(日志总字节数),且为单一消费者扩大了 session-query 公开 API。该公开接缝已回退;`readTitleSnapshots` 继续使用内部 `projectMany`,保持不变。
**只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被否决:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销。`load()` 中的冗余预列表查询仍是一个候选清理项,但涉及错误语义。
**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从接缝角度最干净,但要触碰持久化契约、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费者再引入。
**专门的持久化标题索引或 TUI 本地标题缓存。** 否决:session-projection 缓存本身就是自有的持久 checkpoint 系统,并已带失效契约(`stateVersion`、身份绑定、日志收缩锚定);挂载它优于再造一套并行缓存。
## Consequences
打开 `/resume` 只执行一次列表查询、每个持久化行一次 stat,标题读取在 checkpoint 就绪后只触碰 checkpoint 行和日志尾部——O(会话数) 的元数据开销,而非 O(日志总字节数);无缓存的回退路径仍是一次有界标题扫描。行内只显示标题、时间戳、状态和 id;路由问题以 Enter 时预检错误的形式出现,而不再是禁用行;回放会失败的会话由预检而非列表阶段拦截。浏览后放弃的会话会因 pickup 的 mtime 上浮。TUI 测试中的伪造 `sessionQuery` 服务在 `listSessions`/`readSession` 之外提供 `readTitleSnapshots`,测试 harness 会转发可选的 `locate`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。
+22
View File
@@ -77,6 +77,28 @@
- id: session-reference
name: '@deepseek-ai/dsh-session-reference'
# The projection registry plus its durable checkpoint cache (over the same
# storage root the web surface uses): `/resume` reads titles from the
# zero-I/O checkpoint row or a tail-only cold read instead of scanning
# whole logs, and checkpoints written by either surface serve both.
- id: session-projection
name: '@deepseek-ai/dsh-session-projection'
- id: storage
name: '@deepseek-ai/dsh-storage'
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
root: !!js dshHomePath('storages')
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'
config:
backend: json
- id: session-projection-cache
name: '@deepseek-ai/dsh-session-projection-cache'
config:
writeEveryEvents: 200
writeIntervalMs: 5000
# Terminal-multiplexer context, mounted only where a terminal exists.
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
+3 -1
View File
@@ -2075,6 +2075,8 @@ export interface TuiConfig {
maxModelOptions?: number
/** Maximum sessions visible at once in the resume selector. */
maxResumeOptions?: number
/** Maximum concurrent cold projection reads in one resume scan. */
resumeScanConcurrency?: number
/** User-question panel width in terminal columns, clamped to the terminal. */
questionDialogWidth?: number
/** User-question panel maximum height in terminal rows. */
@@ -2116,7 +2118,7 @@ export interface TuiThemeConfig {
}
```
Source: [`packages/ui/tui/src/config.ts:125`](../packages/ui/tui/src/config.ts)
Source: [`packages/ui/tui/src/config.ts:129`](../packages/ui/tui/src/config.ts)
## `@deepseek-ai/dsh-typert-loader`
+3 -1
View File
@@ -939,6 +939,8 @@ flowchart TD
pkg_tui --> pkg_llm_retry
pkg_tui --> pkg_session
pkg_tui --> pkg_session_persistence
pkg_tui --> pkg_session_projection
pkg_tui --> pkg_session_projection_cache
pkg_tui --> pkg_session_query
pkg_tui --> pkg_session_reference
pkg_tui --> pkg_session_title
@@ -1237,7 +1239,7 @@ flowchart TD
| [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`repository-plugin`](../packages/cordis/repository-plugin) | `cordis` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
+2 -2
View File
@@ -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 packages/ui/tui/README.md
README.md: 5199e75d732f384497b28518920cc2c775c600c2
README.zh.md: 02d4431fdab575b5596152c985b72594a948b081
README.md: 78d56a6cacd040fdd32b73f779bab3fb4c77fcce
README.zh.md: c403605bb13d252eec00a2b0ebafb5f953c884f8
+3 -3
View File
@@ -32,11 +32,11 @@ 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, selected reasoning effort or default behavior, 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 full-viewport keyboard selector instead of a centered dialog. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
`/resume` opens a full-viewport keyboard selector instead of a centered dialog. The selector opens as soon as the command runs and takes input focus while the session scan is still pending, showing a loading placeholder until the rows arrive; Escape cancels an in-flight scan the same way it cancels the loaded list. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
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, and by workspace label in the all-workspaces scope; 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 session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
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. Rows read no whole logs: when the optional projection cache is mounted, titles come from the live projection registry or the durable checkpoint row, with a cold read folding only the log tail since the checkpoint (written back so the next scan is zero-I/O, bounded by `resumeScanConcurrency`); a composition without the cache falls back to one bounded batch title read over the logs. Candidates are sorted by metadata activity — a live session's last in-memory event time, otherwise the persisted artifact's mtime, falling back to creation time — and searchable by title or session id, and by workspace label in the all-workspaces scope; each row reports that timestamp plus current/live/persisted state and the id. 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, or a session with no recorded workspace to run in remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
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` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. 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`.
Selection repeats those checks, fully reads and replay-validates the one chosen log, rejects it when its logged provider has no current adapter, 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` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. 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`.
The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch.
+3 -3
View File
@@ -32,11 +32,11 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。
`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。
`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。选择器在命令执行时立即打开并接管输入焦点,会话扫描仍在进行时显示加载占位符,直到行数据就绪;Escape 取消进行中的扫描,方式与取消已加载列表相同。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。行数据不读取任何完整日志:挂载可选的投影缓存时,标题来自实时投影注册表或持久化 checkpoint 行,冷读取只折叠 checkpoint 之后的日志尾部(并写回,使下次扫描零 I/O,受 `resumeScanConcurrency` 约束);未挂载缓存的组合回退到一次对日志的有界批量标题读取。候选项按元数据活动时间排序——实时会话取内存中最后一个事件的时间,否则取持久化产物的 mtime,再回退到创建时间——可按标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告该时间戳、current/live/persisted 状态和 id。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志,或没有可运行的已记录工作区的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`
选择时会重复这些检查,完整读取并回放验证所选中的那一份日志,在其日志所记提供方没有当前适配器时拒绝,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`
退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。
+4
View File
@@ -42,6 +42,8 @@
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-projection": "^0.0.1",
"@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
@@ -82,6 +84,8 @@
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
+208 -72
View File
@@ -1,16 +1,20 @@
/**
* Session-resume sub-controller for the interactive chat channel: the
* `/resume` selector, per-candidate summary reads that tolerate a corrupt
* `/resume` selector, one metadata-plus-title scan that tolerates a corrupt
* neighbor, the pre-handoff preflight, and the terminal handoff itself.
* @module @deepseek-ai/dsh-tui/chat/resume
*/
import { stat } from 'node:fs/promises'
import type { TUI } from '@earendil-works/pi-tui'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type {} from '@deepseek-ai/dsh-session-projection'
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
import type {} from '@deepseek-ai/dsh-session-title'
import type {
SessionLogSnapshot,
SessionQueryService,
SessionRecord,
} from '@deepseek-ai/dsh-session-query'
@@ -66,50 +70,137 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
const workspaceLabel = (cwd: string | undefined): string =>
runtime.formatCwd?.(cwd) ?? formatCwd(cwd)
/** Build one display candidate without letting a corrupt neighbor abort the selector. */
const readResumeCandidate = async (
/** Summarize one record from metadata and its batch-folded title. */
const summarize = (
record: SessionRecord,
providers: ReadonlySet<string>,
): Promise<ResumeCandidate> => {
title: string | undefined,
lastActivityAt: number | undefined,
): ResumeCandidate => summarizeResumeCandidate(
record,
title,
lastActivityAt,
agent.session.id,
agent.session.header.cwd,
workspaceLabel,
)
/** The disabled fallback row for a session whose title read failed. */
const unreadableCandidate = (
record: SessionRecord,
lastActivityAt: number | undefined,
error: unknown,
): ResumeCandidate => ({
record,
title: 'Unreadable session',
lastActivityAt: lastActivityAt ?? record.header.createdAt,
currentWorkspace: record.header.cwd === agent.session.header.cwd,
workspaceLabel: workspaceLabel(record.header.cwd),
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
})
/**
* Metadata-only activity time: a live session's last in-memory event time,
* otherwise the persisted artifact's mtime. Never reads a log, so browsing
* cost stays independent of log size; any append (including bookkeeping)
* moves it.
*/
const lastActivityAt = async (record: SessionRecord): Promise<number | undefined> => {
const live = ctx.sessions.get(record.header.id)
if (live !== undefined) return live.events.at(-1)?.time
const location = ctx.get('sessionPersistence')?.locate(record.header)
if (location === undefined) return undefined
try {
let snapshot: SessionLogSnapshot
const live = ctx.sessions.get(record.header.id)
if (live !== undefined) {
snapshot = {
session: structuredClone(live.header),
events: live.events.map(event => structuredClone(event)),
}
} else {
const readQuery = sessionQuery()
/* v8 ignore start -- caller proves the optional service before mapping records */
if (readQuery === undefined) throw new Error('session query is unavailable')
/* v8 ignore stop */
snapshot = await readQuery.readSession(record.header.id)
}
return summarizeResumeCandidate(
record,
snapshot,
agent.session.id,
agent.session.header.cwd,
providers,
workspaceLabel,
)
} catch (error: unknown) {
return {
record,
title: 'Unreadable session',
lastActivityAt: record.header.createdAt,
lastTurn: 'log unavailable',
currentWorkspace: record.header.cwd === agent.session.header.cwd,
workspaceLabel: workspaceLabel(record.header.cwd),
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
}
return (await stat(location.path)).mtimeMs
} catch {
// Only a just-deleted or never-materialized artifact fails stat; the row falls back to created-at.
return undefined
}
}
/**
* One persisted row's title through the projection-cache ladder: the
* zero-I/O checkpoint row when usable, otherwise a cold read that folds
* only the log tail since the checkpoint and writes the refreshed row
* back — so a store scanned once serves later scans without log reads.
*/
const projectedTitle = async (
cache: SessionProjectionCache,
record: SessionRecord,
signal: AbortSignal,
): Promise<string | null | undefined> => {
const live = ctx.sessions.get(record.header.id)
if (live !== undefined) return ctx.get('sessionProjections')?.snapshot(live).values.title
const cached = cache.cachedSnapshot(record.header)
if (cached !== undefined && 'title' in cached.values) return cached.values.title
return (await cache.coldSnapshot(record.header.id, signal)).values.title
}
/** One per-record title resolution: a title (absent for untitled) or an isolated failure. */
type TitleResolution = { title?: string; failure?: unknown }
/**
* Resolve every row's title without reading whole logs when the projection
* cache is mounted (live registry snapshot / checkpoint row / tail-only
* cold read, bounded by `resumeScanConcurrency`); a composition without
* the cache falls back to one bounded raw-log title batch.
*/
const resolveTitles = async (
listQuery: SessionQueryService,
records: readonly SessionRecord[],
signal: AbortSignal,
): Promise<TitleResolution[]> => {
const cache = ctx.get('sessionProjectionCache')
if (cache === undefined) {
const results = await listQuery.readTitleSnapshots(records.map(record => record.header.id), signal)
return records.map((record, index): TitleResolution => {
const result = results[index]
/* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */
if (result === undefined || result.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`)
if (result.status === 'rejected') return { failure: result.reason }
const title = result.value.title?.title
return title === undefined ? {} : { title }
})
}
const resolutions = new Array<TitleResolution>(records.length)
let cursor = 0
const worker = async (): Promise<void> => {
for (;;) {
const index = cursor
if (index >= records.length) return
cursor += 1
const record = records[index] as SessionRecord
try {
const value = await projectedTitle(cache, record, signal)
resolutions[index] = typeof value === 'string' ? { title: value } : {}
} catch (failure: unknown) {
resolutions[index] = { failure }
}
}
}
await Promise.all(Array.from(
{ length: Math.min(resolved.resumeScanConcurrency, records.length) },
() => worker(),
))
return resolutions
}
/** The latest logged provider/model route, for the preflight availability check. */
const resumeRoute = (events: readonly SessionEvent[]): { provider: string; model: string } | undefined => {
const header = events.findLast(item => item.type === 'request/header')
if (header?.type === 'request/header') {
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
}
const assistant = events.findLast(item => item.type === 'assistant/message')
return assistant?.type === 'assistant/message'
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
: undefined
}
/**
* Re-read every mutable precondition immediately before terminal handoff and
* resolve the exact identity and workspace the host will re-exec into.
* resolve the exact identity and workspace the host will re-exec into. This
* is where the one chosen log is fully read, replay-validated, and checked
* for a currently-available route — the listing never does any of that.
*/
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
const query = sessionQuery()
@@ -120,17 +211,24 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId)
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
const candidate = await readResumeCandidate(
record,
new Set(ctx.llm.listProviders().map(provider => provider.id)),
)
const candidate = summarize(record, undefined, undefined)
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
const cwd = candidate.record.header.cwd
let events: readonly SessionEvent[]
try {
events = (await query.readSession(record.header.id)).events
} catch (error: unknown) {
throw new Error(`session cannot be loaded: ${errorChain(error)}`)
}
const route = resumeRoute(events)
if (route !== undefined && !ctx.llm.listProviders().some(provider => provider.id === route.provider)) {
throw new Error(`session is complete, but route is currently unavailable (${route.provider}/${route.model})`)
}
const cwd = record.header.cwd
/* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */
if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`)
const finalStatus = deps.agentStatus()
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
return { id: candidate.record.header.id, cwd }
return { id: record.header.id, cwd }
}
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
@@ -194,40 +292,78 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
}
const scan = ++resumeScan
void resumeOverlay?.close()
void listQuery.listSessions().then(async (records) => {
if (deps.isDisposed() || scan !== resumeScan) return
// Every workspace in the store is summarized; the picker owns the
// current-workspace/all-workspaces scope split over the whole set.
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
const candidates = await Promise.all(records.map(record => readResumeCandidate(record, providers)))
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|| a.record.header.id.localeCompare(b.record.header.id))
if (deps.isDisposed() || scan !== resumeScan) return
const session = overlayManager.open({
create: host => new ResumePicker(
candidates,
// The picker opens before the scan settles so the terminal stops feeding
// the editor immediately; a queued activation (the closing predecessor
// still holds the slot) receives an already-scanned set through
// `scanned` instead of a loading placeholder.
let picker: ResumePicker | undefined
let scanned: ResumeCandidate[] | undefined
const session = overlayManager.open({
create: (host) => {
picker = new ResumePicker(
scanned,
resolved.maxResumeOptions,
workspaceLabel(agent.session.header.cwd),
() => host.viewport.rows,
palette,
(candidate) => { void handoffResume(candidate, session) },
() => { void session.close() },
),
options: {
width: '100%',
maxHeight: '100%',
anchor: 'top-left',
margin: 0,
},
})
resumeOverlay = session
void session.closed.then(() => {
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
if (resumeOverlay === session) resumeOverlay = undefined
)
return picker
},
options: {
width: '100%',
maxHeight: '100%',
anchor: 'top-left',
margin: 0,
},
})
resumeOverlay = session
// Closing the picker — Escape, supersession, disposal — aborts the scan:
// the borrowed-log pass over a large store must not outlive its overlay.
const scanAbort = new AbortController()
void session.closed.then(() => {
scanAbort.abort()
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
if (resumeOverlay === session) resumeOverlay = undefined
})
deps.requestRender()
/** Whether this scan's overlay, session generation, or TUI is gone. */
const scanStale = (): boolean =>
deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted
const scanCandidates = async (): Promise<void> => {
// Every workspace in the store is listed; the picker owns the
// current-workspace/all-workspaces scope split over the whole set.
const records = await listQuery.listSessions(scanAbort.signal)
if (scanStale()) return
// Rows need only metadata, an mtime, and a title — resolved without
// whole-log reads when the projection cache is mounted. A corrupt
// neighbor degrades to one disabled row.
const [titles, activity] = await Promise.all([
resolveTitles(listQuery, records, scanAbort.signal),
Promise.all(records.map(record => lastActivityAt(record))),
])
const candidates = records.map((record, index) => {
const resolution = titles[index] as TitleResolution
return 'failure' in resolution
? unreadableCandidate(record, activity[index], resolution.failure)
: summarize(record, resolution.title, activity[index])
})
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|| a.record.header.id.localeCompare(b.record.header.id))
if (scanStale()) return
scanned = candidates
picker?.setCandidates(candidates)
deps.requestRender()
}, (error: unknown) => {
if (!deps.isDisposed() && scan === resumeScan) deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
}
// One catch covers listing, titles, and mtimes, so a scan failure
// cannot strand the overlay on its loading placeholder; an aborted
// scan's rejection stays silent because the user already dismissed the
// picker.
void scanCandidates().catch((error: unknown) => {
if (scanStale()) return
void session.close()
deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
})
},
}
+54 -80
View File
@@ -23,14 +23,8 @@ import {
type AgentLlmTarget,
} from '@deepseek-ai/dsh-agent'
import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { lastActivityTime } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
SessionLogSnapshot,
SessionRecord,
} from '@deepseek-ai/dsh-session-query'
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
import { dialogSelectTheme, type Palette } from './theme.ts'
@@ -506,95 +500,53 @@ export class DetailsDialog implements Component {
}
}
/** The provider/model route recovered from a resume candidate's log. */
export interface ResumeRoute {
provider: string
model: string
}
/** A preflighted resume selector row summarizing one persisted session. */
/** A resume selector row summarizing one session from metadata and its folded title. */
export interface ResumeCandidate {
record: SessionRecord
title: string
/** Last observed change: live last-event time or artifact mtime, falling back to creation. */
lastActivityAt: number
lastTurn: string
/** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */
currentWorkspace: boolean
/** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */
workspaceLabel: string
route?: ResumeRoute
goalPhase?: GoalPhase
disabledReason?: string
}
function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
const event = snapshot.events.findLast(item => item.type === 'turn/end')
if (event === undefined) return 'no completed turn'
const reason = event.data.reason
switch (reason.kind) {
case 'completed': return `turn ${event.data.turn}: completed`
case 'aborted': return `turn ${event.data.turn}: cancelled`
case 'error': return `turn ${event.data.turn}: error`
case 'disposed': return `turn ${event.data.turn}: disposed`
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
case 'interrupted': return `turn ${event.data.turn}: interrupted`
default: return `turn ${event.data.turn}: unknown result`
}
}
function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
const header = snapshot.events.findLast(item => item.type === 'request/header')
if (header?.type === 'request/header') {
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
}
const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
return assistant?.type === 'assistant/message'
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
: undefined
}
/**
* Build one resume selector row from a record and its log snapshot, deriving the
* title, route, goal phase, workspace scope, and any reason the session cannot
* be resumed here. A workspace other than the current one is a scope, not a
* disabled reason: resuming it hands the process off into that directory.
* Build one resume selector row from a record, its batch-folded title, and a
* metadata-derived activity time, deriving the workspace scope and any reason
* the session cannot be resumed here. A workspace other than the current one
* is a scope, not a disabled reason: resuming it hands the process off into
* that directory. Rows carry no per-log detail beyond the title — route and
* replay validity are checked by the Enter-time preflight against the one
* chosen log.
* @param record - The session record.
* @param snapshot - The session's log snapshot.
* @param title - The session's batch-folded title, absent for an untitled log.
* @param lastActivityAt - Metadata activity time; absent falls back to the header's creation time.
* @param currentId - The current session id.
* @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in.
* @param availableProviders - Providers registered in this runtime.
* @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label.
* @returns The summarized resume candidate.
*/
export function summarizeResumeCandidate(
record: SessionRecord,
snapshot: SessionLogSnapshot,
title: string | undefined,
lastActivityAt: number | undefined,
currentId: SessionId,
cwd: string | undefined,
availableProviders: ReadonlySet<string>,
formatWorkspace: (cwd: string | undefined) => string,
): ResumeCandidate {
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
const route = resumeRoute(snapshot)
const foldedGoal = foldGoal(snapshot.events).goal
let disabledReason: string | undefined
if (record.header.id === currentId) disabledReason = 'current session'
else if (record.live) disabledReason = 'session is already live in this runtime'
else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace'
else if (route !== undefined && !availableProviders.has(route.provider)) {
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
}
return {
record,
title,
// Excludes a prior pickup's boundary, or every browsed session floats up.
lastActivityAt: lastActivityTime(snapshot.events) ?? snapshot.session.createdAt,
lastTurn: resumeTurnLabel(snapshot),
title: title ?? 'Untitled session',
lastActivityAt: lastActivityAt ?? record.header.createdAt,
currentWorkspace: record.header.cwd === cwd,
workspaceLabel: formatWorkspace(record.header.cwd),
...route === undefined ? {} : { route },
/* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
...disabledReason === undefined ? {} : { disabledReason },
}
}
@@ -609,6 +561,10 @@ export type ResumeScope = 'workspace' | 'all'
* current session's workspace, `all` lists every workspace and labels each row
* with its own. Tab toggles between them; the search query and selection reset
* on a scope change so the highlighted row always belongs to the visible list.
*
* The picker opens before the session scan settles: an `undefined` candidate
* set renders a loading placeholder that keeps input away from the editor,
* and `setCandidates` swaps the scanned rows in without replacing the overlay.
*/
export class ResumePicker implements Component, Focusable {
private readonly search = new Input()
@@ -616,27 +572,43 @@ export class ResumePicker implements Component, Focusable {
private selectedIndex = 0
private error = ''
private scope: ResumeScope = 'workspace'
private candidates: readonly ResumeCandidate[] | undefined
focused = false
constructor(
private readonly candidates: readonly ResumeCandidate[],
candidates: readonly ResumeCandidate[] | undefined,
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,
) {}
) {
this.candidates = candidates
}
invalidate(): void {
this.search.invalidate()
}
/**
* Replace the loading placeholder with the scanned candidate set.
* @param candidates - the summarized rows the finished scan produced.
*/
setCandidates(candidates: readonly ResumeCandidate[]): void {
this.candidates = candidates
this.selectedIndex = 0
// A still-loading error is false the moment rows exist.
this.error = ''
this.invalidate()
}
/** Candidates in the active scope, before the search query narrows them. */
private scoped(): ResumeCandidate[] {
const candidates = this.candidates ?? []
return this.scope === 'all'
? [...this.candidates]
: this.candidates.filter(candidate => candidate.currentWorkspace)
? [...candidates]
: candidates.filter(candidate => candidate.currentWorkspace)
}
private filtered(): ResumeCandidate[] {
@@ -653,7 +625,7 @@ export class ResumePicker implements Component, Focusable {
private visibleCandidateCount(): number {
// The all-workspaces scope adds a per-row workspace line, so a row costs
// one more terminal row there than in the single-workspace scope.
const rowHeight = this.scope === 'all' ? 5 : 4
const rowHeight = this.scope === 'all' ? 4 : 3
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight))
return Math.min(this.maxVisible, candidateBudget)
}
@@ -718,7 +690,8 @@ export class ResumePicker implements Component, Focusable {
this.error = ''
} else if (matchesKey(data, Key.enter)) {
const selected = filtered[this.selectedIndex]
if (selected === undefined) this.error = 'No session matches this search.'
if (this.candidates === undefined) this.error = 'Sessions are still loading.'
else if (selected === undefined) this.error = 'No session matches this search.'
else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
else this.done(selected)
} else {
@@ -738,12 +711,13 @@ export class ResumePicker implements Component, Focusable {
* workspace it means, and the inactive scope with the count Tab would reveal.
*/
private renderScopeLine(): string {
const inWorkspace = this.candidates.filter(candidate => candidate.currentWorkspace).length
const candidates = this.candidates ?? []
const inWorkspace = candidates.filter(candidate => candidate.currentWorkspace).length
const active = this.scope === 'workspace'
? `this workspace ${displayText(this.workspaceLabel)}`
: `all workspaces (${this.candidates.length})`
: `all workspaces (${candidates.length})`
const other = this.scope === 'workspace'
? `all workspaces (${this.candidates.length})`
? `all workspaces (${candidates.length})`
: `this workspace (${inWorkspace})`
return `${this.palette.accent(active)}${this.palette.dim(`${other}`)}`
}
@@ -758,9 +732,12 @@ export class ResumePicker implements Component, Focusable {
if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
const selected = filtered[this.selectedIndex]
const position = selected === undefined ? 0 : this.selectedIndex + 1
const title = this.candidates === undefined
? 'Resume session'
: `Resume session (${position} of ${filtered.length})`
const lines: string[] = [
'',
`${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`,
`${indent}${this.palette.bold(this.palette.accent(title))}`,
'',
]
@@ -795,11 +772,7 @@ export class ResumePicker implements Component, Focusable {
].filter((value): value is string => value !== undefined).join(' · ')
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}`
/* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${status} · ${displayText(candidate.record.header.id)}`))
// Only the all-workspaces scope mixes directories, so the per-row
// workspace is redundant in the scope that already names one.
if (this.scope === 'all') {
@@ -809,7 +782,8 @@ export class ResumePicker implements Component, Focusable {
push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
}
}
if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
if (this.candidates === undefined) push(this.palette.dim('Loading sessions'))
else if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
if (this.error !== '') {
lines.push('')
push(this.palette.error(displayText(this.error)))
+6
View File
@@ -42,6 +42,8 @@ export interface TuiConfig {
maxModelOptions?: number
/** Maximum sessions visible at once in the resume selector. */
maxResumeOptions?: number
/** Maximum concurrent cold projection reads in one resume scan. */
resumeScanConcurrency?: number
/** User-question panel width in terminal columns, clamped to the terminal. */
questionDialogWidth?: number
/** User-question panel maximum height in terminal rows. */
@@ -72,6 +74,7 @@ const maxDiffEditLengthSchema = z.number().step(1).min(1).default(1000)
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
const resumeScanConcurrencySchema = z.number().step(1).min(1).default(4)
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(76)
@@ -105,6 +108,7 @@ const tuiConfigSchemaFields = {
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
maxResumeOptions: maxResumeOptionsSchema,
resumeScanConcurrency: resumeScanConcurrencySchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
@@ -178,6 +182,7 @@ export interface ResolvedTuiConfig {
maxQuestionOptions: number
maxModelOptions: number
maxResumeOptions: number
resumeScanConcurrency: number
questionDialogWidth: number
questionDialogMaxHeight: number
modelDialogWidth: number
@@ -205,6 +210,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
maxModelOptions: config?.maxModelOptions ?? 8,
maxResumeOptions: config?.maxResumeOptions ?? 8,
resumeScanConcurrency: config?.resumeScanConcurrency ?? 4,
questionDialogWidth: config?.questionDialogWidth ?? 200,
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 76,
+3 -1
View File
@@ -69,6 +69,8 @@ export interface TuiHarnessOptions {
sessionPersistence?: {
list(): Promise<SessionHeader[]>
load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
/** Per-session artifact location for mtime-based activity; defaults to none. */
locate?(meta: SessionHeader): { kind: string; path: string } | undefined
}
handoffResume?: TuiRuntime['handoffResume']
/** Host-supplied exit line; absent exercises the no-message path. */
@@ -156,7 +158,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const persistence = options.sessionPersistence
ctx.provide('sessionPersistence', {
...persistence,
locate: () => undefined,
locate: (meta: SessionHeader) => persistence.locate?.(meta),
create: () => Promise.resolve(),
append: () => Promise.resolve(),
load: persistence.load === undefined
@@ -22,28 +22,25 @@ buffer
8| " "
9| " Untitled session "
style 2-19 fg=bright-magenta bold
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
style 2-67 dim
11| " current · live · main-session "
style 2-32 dim
12| " workspace /workspace/project "
10| " 2026-07-23T08:00:00.000Z · current · live · main-session "
style 2-59 dim
11| " workspace /workspace/project "
style 2-31 dim
13| " unavailable: current session "
12| " unavailable: current session "
style 2-31 fg=yellow
14| " Other workspace work "
15| " 2024-02-02T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
style 2-83 dim
16| " persisted · elsewhere-session "
style 2-32 dim
17| " workspace /workspace/other "
13| " Other workspace work "
14| " 2024-02-02T00:00:00.000Z · persisted · elsewhere-session "
style 2-59 dim
15| " workspace /workspace/other "
style 2-29 dim
18| " Resume selector design "
19| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
style 2-83 dim
20| " persisted · earlier-session "
style 2-30 dim
21| " workspace /workspace/project "
16| " Resume selector design "
17| " 2024-01-01T00:00:00.000Z · persisted · earlier-session "
style 2-57 dim
18| " workspace /workspace/project "
style 2-31 dim
19| " "
20| " "
21| " "
22| " "
23| " "
24| " "
@@ -0,0 +1,47 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=6 viewportRow=4 bufferRow=4
buffer
0| " "
1| " Resume session "
style 2-15 fg=bright-magenta 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| " this workspace /workspace/project ⇥ all workspaces (0) "
style 2-34 fg=bright-magenta
style 35-56 dim
8| " "
9| " Loading sessions… "
style 2-18 dim
10| " "
11| " "
12| " "
13| " "
14| " "
15| " "
16| " "
17| " "
18| " "
19| " "
20| " "
21| " "
22| " "
23| " "
24| " "
25| " "
26| " "
27| " "
28| " "
29| " "
30| " Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel "
style 2-84 dim
31| " "
@@ -22,17 +22,15 @@ buffer
8| " "
9| " Untitled session "
style 2-19 fg=bright-magenta bold
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
style 2-67 dim
11| " current · live · main-session "
style 2-32 dim
12| " unavailable: current session "
10| " 2026-07-23T08:00:00.000Z · current · live · main-session "
style 2-59 dim
11| " unavailable: current session "
style 2-31 fg=yellow
13| " Resume selector design "
14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
style 2-83 dim
15| " persisted · earlier-session "
style 2-30 dim
12| " Resume selector design "
13| " 2024-01-01T00:00:00.000Z · persisted · earlier-session "
style 2-57 dim
14| " "
15| " "
16| " "
17| " "
18| " "
+18 -6
View File
@@ -62,6 +62,7 @@ const CHECKPOINTS = [
'model-switching',
'errors-and-help',
'disposed-terminal',
'resume-sessions-loading',
'resume-sessions',
'resume-sessions-all-workspaces',
'status-diagnostics',
@@ -981,14 +982,18 @@ describe('TUI terminal-state snapshots', () => {
{ type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
// A prior pickup, dated well after the work: the picker must still
// show the work's date, not the pickup's.
{ type: 'session/end-seed', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} },
],
})
const listGate = Promise.withResolvers<undefined>()
// Rows show metadata activity (here the created-at fallback: the fake
// store locates no per-session artifact to stat) plus each log's one
// batch-folded title; nothing else is read from the logs.
const harness = await setupSnapshot({
sessionPersistence: {
list: async () => [earlier, elsewhere],
list: async () => {
await listGate.promise
return [earlier, elsewhere]
},
load: async id => id === elsewhere.id
? log(elsewhere, 'Other workspace work', '2024-02-02')
: log(earlier, 'Resume selector design', '2024-01-01'),
@@ -996,8 +1001,15 @@ describe('TUI terminal-state snapshots', () => {
}, { columns: 92, rows: 32 })
harness.terminal.send('/resume')
harness.terminal.send('\r')
// `/resume` scans persistence asynchronously, so the listing renders a tick
// after submit (the unit suite waits the same way); settle, then flush.
// The picker opens as soon as the command dispatches and owns input while
// the persistence scan is still pending, rendering a loading placeholder
// in place of rows; only the scan is gated, so this settle never lists.
await new Promise(resolve => setTimeout(resolve, 60))
await harness.terminal.flush()
await checkpoint('resume-sessions-loading', harness.terminal, { includeScrollback: true })
listGate.resolve(undefined)
// With the scan released, the listing renders a tick later (the unit suite
// waits the same way); settle, then flush.
await new Promise(resolve => setTimeout(resolve, 60))
await harness.terminal.flush()
await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true })
+346 -40
View File
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
@@ -42,7 +42,8 @@ import {
type TuiRuntime,
} from '../src/index.ts'
import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts'
import { ATTRIBUTE_ROLES, brandText, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
import { ResumePicker } from '../src/components/dialogs.ts'
import { ATTRIBUTE_ROLES, brandText, COLOR_ROLES, createPalette, paletteSpec } from '../src/components/theme.ts'
import {
appendAssistant,
appendUser,
@@ -190,6 +191,7 @@ describe('TUI config', () => {
maxQuestionOptions: 8,
maxModelOptions: 8,
maxResumeOptions: 8,
resumeScanConcurrency: 4,
questionDialogWidth: 200,
questionDialogMaxHeight: 20,
modelDialogWidth: 76,
@@ -216,6 +218,7 @@ describe('TUI config', () => {
maxQuestionOptions: 3,
maxModelOptions: 4,
maxResumeOptions: 5,
resumeScanConcurrency: 2,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
@@ -234,6 +237,7 @@ describe('TUI config', () => {
maxQuestionOptions: 3,
maxModelOptions: 4,
maxResumeOptions: 5,
resumeScanConcurrency: 2,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
@@ -286,6 +290,23 @@ describe('goodbye message and /resume', () => {
{ type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } },
{ type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
]
/** Derive the selector's batch title read from a fake per-session readSession. */
const titlesViaReadSession = (
readSession: (id: SessionId) => Promise<{ session: SessionHeader; events: SessionEvent[] }>,
) => (ids: readonly SessionId[]) => Promise.all(ids.map(async (sessionId) => {
try {
const snapshot = await readSession(sessionId)
const titleEvent = snapshot.events.findLast(event => event.type === 'session/title')
const title = titleEvent?.type === 'session/title' ? { title: titleEvent.data.title } : undefined
return {
sessionId,
status: 'fulfilled',
value: { session: snapshot.session, ...title === undefined ? {} : { title } },
}
} catch (reason) {
return { sessionId, status: 'rejected', reason }
}
}))
it('prints the host goodbye message on exit', async () => {
const result = await setup({
@@ -443,7 +464,7 @@ describe('goodbye message and /resume', () => {
result.terminal.send('\x1b[6~')
await tick()
const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
expect(rendered).toContain(' Paged 3')
expect(rendered).toContain(' Paged 5')
result.terminal.send('\x1b[5~')
await tick()
expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
@@ -476,26 +497,128 @@ describe('goodbye message and /resume', () => {
await dispose(result)
})
it.each([
[{ kind: 'aborted' }, 'cancelled'],
[{ kind: 'error', step: 1, message: 'failed' }, 'error'],
[{ kind: 'disposed' }, 'disposed'],
[{ kind: 'max-tokens' }, 'max tokens'],
[{ kind: 'interrupted' }, 'interrupted'],
[{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'],
] as const)('renders the last turn result %s', async (reason, label) => {
const target = header(`turn-${label}`, 10, '/workspace')
it('resolves titles through the projection cache without scanning logs', async () => {
const current = header('main-session', 5, '/workspace')
const cachedRow = header('cached-title', 40, '/workspace')
const rowless = header('rowless-title', 30, '/workspace')
const untitled = header('untitled-title', 20, '/workspace')
const broken = header('broken-title', 10, '/workspace')
let coldReads = 0
const result = await setup({
cwd: '/workspace',
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek-official', 100, reason) }),
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', {
listSessions: () => Promise.resolve([
{ header: current, live: true, persisted: false },
{ header: cachedRow, live: false, persisted: true },
{ header: rowless, live: false, persisted: true },
{ header: untitled, live: false, persisted: true },
{ header: broken, live: false, persisted: true },
]),
readTitleSnapshots: () => Promise.reject(new Error('the ladder must not scan logs')),
} as never)
ctx.provide('sessionProjections', {
snapshot: () => ({ asOfSeq: 0, values: { title: 'Live projected' } }),
} as never)
ctx.provide('sessionProjectionCache', {
cachedSnapshot: (meta: SessionHeader) => {
if (meta.id === cachedRow.id) return { asOfSeq: 3, values: { title: 'Cached projected' } }
if (meta.id === untitled.id) return { asOfSeq: 3, values: { title: null } }
if (meta.id === rowless.id) return { asOfSeq: 3, values: {} }
return undefined
},
coldSnapshot: async (id: SessionId) => {
coldReads += 1
if (id === broken.id) throw new Error('checkpoint restore failed')
return { asOfSeq: 5, values: { title: 'Cold projected' } }
},
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain(`turn 1: ${label}`)
expect(result.terminal.output).toContain('Live projected')
expect(result.terminal.output).toContain('Cached projected')
expect(result.terminal.output).toContain('Cold projected')
expect(result.terminal.output).toContain('Untitled session')
expect(result.terminal.output).toContain('Unreadable session')
expect(result.terminal.output).toContain('checkpoint restore failed')
expect(result.terminal.output).not.toContain('the ladder must not scan logs')
expect(coldReads).toBe(2)
await dispose(result)
})
it('shows a live row untitled when the cache is mounted without the registry', async () => {
const current = header('main-session', 5, '/workspace')
const result = await setup({
cwd: '/workspace',
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', {
listSessions: () => Promise.resolve([{ header: current, live: true, persisted: false }]),
} as never)
ctx.provide('sessionProjectionCache', {
cachedSnapshot: () => undefined,
coldSnapshot: async () => ({ asOfSeq: -1, values: {} }),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('Untitled session')
await dispose(result)
})
it('orders rows by artifact mtime without reading logs for the timestamp', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-resume-mtime-'))
const stale = join(dir, 'stale.log')
const fresh = join(dir, 'fresh.log')
await writeFile(stale, 'x')
await writeFile(fresh, 'x')
await utimes(stale, new Date(1000), new Date(60_000))
await utimes(fresh, new Date(1000), new Date(120_000))
// Creation order contradicts mtime order, so the sort proves its source.
const createdLate = header('created-late-touched-early', 50, '/workspace')
const createdEarly = header('created-early-touched-late', 40, '/workspace')
const gone = header('artifact-gone', 30, '/workspace')
const goneTwin = header('artifact-gone-twin', 30, '/workspace')
const paths = new Map([
[createdLate.id, stale],
[createdEarly.id, fresh],
[gone.id, join(dir, 'missing.log')],
[goneTwin.id, join(dir, 'missing-twin.log')],
])
const titles = new Map([
[createdLate.id, 'Touched early'],
[createdEarly.id, 'Touched late'],
[gone.id, 'Artifact gone'],
[goneTwin.id, 'Artifact gone twin'],
])
const result = await setup({
cwd: '/workspace',
sessionPersistence: {
list: async () => [createdLate, createdEarly, gone, goneTwin],
load: async id => ({
meta: [createdLate, createdEarly, gone, goneTwin].find(target => target.id === id)!,
events: resumeEvents(titles.get(id)!),
}),
locate: meta => ({ kind: 'jsonl', path: paths.get(meta.id)! }),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
expect(rendered).toContain(new Date(120_000).toISOString())
expect(rendered.indexOf('Touched late')).toBeLessThan(rendered.indexOf('Touched early'))
// A missing artifact falls back to the header's creation time; equal
// times tie-break by id.
expect(rendered).toContain(new Date(gone.createdAt).toISOString())
expect(rendered.indexOf('artifact-gone')).toBeLessThan(rendered.indexOf('artifact-gone-twin'))
await rm(dir, { recursive: true, force: true })
await dispose(result)
})
@@ -529,6 +652,7 @@ describe('goodbye message and /resume', () => {
queryCtx = child
child.provide('sessionQuery', {
listSessions: async () => { listCalls++; return [] },
readTitleSnapshots: async () => [],
} as never)
},
})
@@ -559,16 +683,18 @@ describe('goodbye message and /resume', () => {
cwd: '/workspace',
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
const readSession = () => Promise.resolve({
session: target,
events: resumeEvents('Query-only persisted session'),
})
ctx.provide('sessionQuery', {
listSessions: () => Promise.resolve([{
header: target,
live: false,
persisted: true,
}]),
readSession: () => Promise.resolve({
session: target,
events: resumeEvents('Query-only persisted session'),
}),
readSession,
readTitleSnapshots: titlesViaReadSession(readSession),
} as never)
},
})
@@ -606,11 +732,17 @@ describe('goodbye message and /resume', () => {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', {
listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]),
readTitleSnapshots: async () => [],
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
// The loading picker owns input as soon as /resume runs, so the second
// scan starts after dismissing the first overlay, not by typing a second
// slash command over it.
result.terminal.send('\u001B')
await tick()
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
@@ -641,6 +773,127 @@ describe('goodbye message and /resume', () => {
expect(result.terminal.stopped).toBeGreaterThan(0)
})
it('clears the still-loading error the moment scanned rows arrive', () => {
const picker = new ResumePicker(
undefined,
10,
'/workspace',
() => 30,
createPalette(false),
() => {},
() => {},
)
picker.focused = true
picker.handleInput('\r')
expect(picker.render(80).join('\n')).toContain('Sessions are still loading.')
picker.setCandidates([])
const rendered = picker.render(80).join('\n')
expect(rendered).not.toContain('Sessions are still loading.')
expect(rendered).toContain('No matching sessions.')
})
it('aborts an in-flight scan when the loading picker is dismissed', async () => {
const listing = Promise.withResolvers<SessionRecord[]>()
let scanSignal: AbortSignal | undefined
let projections = 0
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', {
listSessions: (signal?: AbortSignal) => { scanSignal = signal; return listing.promise },
readTitleSnapshots: async () => { projections += 1; return [] },
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Loading sessions…')
result.terminal.send('\u001B')
await tick()
expect(scanSignal?.aborted).toBe(true)
// A signal-ignoring backend can still fulfill after dismissal: the stale
// scan must neither read titles nor report.
listing.resolve([])
await tick()
expect(projections).toBe(0)
expect(result.terminal.output).not.toContain('Resume session scan failed')
await dispose(result)
})
it('drops a title read that settles after the picker was dismissed', async () => {
const projecting = Promise.withResolvers<never[]>()
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', {
listSessions: async () => [],
readTitleSnapshots: () => projecting.promise,
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
result.terminal.send('\u001B')
await tick()
projecting.resolve([])
await tick()
expect(result.terminal.output).not.toContain('(0 of 0)')
expect(result.terminal.output).not.toContain('Resume session scan failed')
await dispose(result)
})
it('closes the loading picker and reports a scan that fails after listing', async () => {
const target = header('titles-explode', 10, '/workspace')
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', {
listSessions: () => Promise.resolve([{ header: target, live: false, persisted: true }]),
readTitleSnapshots: () => Promise.reject(new Error('titles exploded')),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('Resume session scan failed: titles exploded')
expect(result.terminal.stopped).toBe(0)
await dispose(result)
})
it('opens a loading picker immediately and swaps in the scanned rows', async () => {
const target = header('late-listing', 10, '/workspace')
const listing = Promise.withResolvers<SessionRecord[]>()
const result = await setup({
cwd: '/workspace',
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
const readSession = () => Promise.resolve({
session: target,
events: resumeEvents('Late listing'),
})
ctx.provide('sessionQuery', {
listSessions: () => listing.promise,
readSession,
readTitleSnapshots: titlesViaReadSession(readSession),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Loading sessions…')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Sessions are still loading.')
listing.resolve([{ header: target, live: false, persisted: true }])
await tick(); await tick()
expect(result.terminal.output).toContain('Late listing')
await dispose(result)
})
it('drops loaded selector summaries when the TUI disposed during log reads', async () => {
const target = header('dispose-during-load', 10, '/workspace')
const loading = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>()
@@ -680,11 +933,12 @@ describe('goodbye message and /resume', () => {
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('Missing adapter')
expect(result.terminal.output).toContain('absent-provider/model-1')
// Rows carry no route: availability surfaces only at Enter-time preflight.
expect(result.terminal.output).not.toContain('absent-provider/model-1')
expect(result.terminal.output).toContain('Unreadable session')
result.terminal.send('Missing adapter')
result.terminal.send('\r')
await tick()
await tick(); await tick()
expect(result.terminal.output).toContain('route is currently unavailable')
expect(result.terminal.stopped).toBe(0)
await dispose(result)
@@ -698,16 +952,18 @@ describe('goodbye message and /resume', () => {
handoffResume: handoff,
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
const readSession = () => Promise.resolve({
session: target,
events: resumeEvents('Live target'),
})
ctx.provide('sessionQuery', {
listSessions: () => Promise.resolve([{
header: target,
live: true,
persisted: true,
}]),
readSession: () => Promise.resolve({
session: target,
events: resumeEvents('Live target'),
}),
readSession,
readTitleSnapshots: titlesViaReadSession(readSession),
} as never)
},
})
@@ -722,10 +978,45 @@ describe('goodbye message and /resume', () => {
await dispose(result)
})
it('rechecks record liveness at preflight rather than trusting the listed row', async () => {
const target = header('turns-live', 10, '/workspace')
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
let listings = 0
const result = await setup({
cwd: '/workspace',
handoffResume: handoff,
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
const readSession = () => Promise.resolve({
session: target,
events: resumeEvents('Turns live'),
})
ctx.provide('sessionQuery', {
listSessions: () => Promise.resolve([{
header: target,
live: ++listings > 1,
persisted: true,
}]),
readSession,
readTitleSnapshots: titlesViaReadSession(readSession),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Turns live')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('session is already live in this runtime')
expect(handoff).not.toHaveBeenCalled()
await dispose(result)
})
it('falls back to assistant provenance and header creation time for sparse logs', async () => {
const assistantOnly = header('assistant-route', 20, '/workspace')
const empty = header('empty-log', 10, '/workspace')
const events = resumeEvents('Assistant route', 'deepseek-official')
const events = resumeEvents('Assistant route', 'absent-provider')
.filter(event => event.type !== 'request/header')
.map((event, seq) => ({ ...event, seq })) as SessionEvent[]
const result = await setup({
@@ -740,8 +1031,23 @@ describe('goodbye message and /resume', () => {
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('deepseek-official/model-1')
// Without a persisted artifact to stat, listing falls back to creation time.
expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString())
// The preflight route fold falls back to assistant provenance when the
// log carries no request header.
result.terminal.send('Assistant route')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('route is currently unavailable')
// The failed preflight closed the picker; reopen and pick the routeless
// log, which passes the route check — only the absent host stops it.
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('empty-log')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('cannot hand it off in place')
await dispose(result)
})
@@ -828,12 +1134,14 @@ describe('goodbye message and /resume', () => {
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.on('session/flush', flush)
const readSession = () => Promise.resolve({
session: target,
events: resumeEvents('Dispose during preflight'),
})
ctx.provide('sessionQuery', {
listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise,
readSession: () => Promise.resolve({
session: target,
events: resumeEvents('Dispose during preflight'),
}),
readSession,
readTitleSnapshots: titlesViaReadSession(readSession),
} as never)
},
})
@@ -860,16 +1168,18 @@ describe('goodbye message and /resume', () => {
handoffResume: handoff,
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
const readSession = () => Promise.resolve({
session: target,
events: resumeEvents('Query without persistence'),
})
ctx.provide('sessionQuery', {
listSessions: () => Promise.resolve([{
header: target,
live: false,
persisted: true,
}]),
readSession: () => Promise.resolve({
session: target,
events: resumeEvents('Query without persistence'),
}),
readSession,
readTitleSnapshots: titlesViaReadSession(readSession),
} as never)
},
})
@@ -1265,10 +1575,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
})
expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed')
expect(result.terminal.output).toContain('/goal resume')
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('goal active')
await dispose(result)
})
+6
View File
@@ -32,6 +32,12 @@
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../session-projection/session-projection-cache"
},
{
"path": "../../session-query/session-query"
},
+6
View File
@@ -5881,6 +5881,12 @@ importers:
'@deepseek-ai/dsh-session-persistence':
specifier: workspace:^
version: link:../../session-persistence/session-persistence
'@deepseek-ai/dsh-session-projection':
specifier: workspace:^
version: link:../../session-projection/session-projection
'@deepseek-ai/dsh-session-projection-cache':
specifier: workspace:^
version: link:../../session-projection/session-projection-cache
'@deepseek-ai/dsh-session-query':
specifier: workspace:^
version: link:../../session-query/session-query