diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml new file mode 100644 index 0000000000..3c6718b975 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md new file mode 100644 index 0000000000..39146527f1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md new file mode 100644 index 0000000000..10333ea7cc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -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` 会落入搜索字段,这正是预期的输入捕获行为。 diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml index 02d8649447..02b048648f 100644 --- a/apps/cli/config/tui.cordis.yml +++ b/apps/cli/config/tui.cordis.yml @@ -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' diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 617cfb82be..6a055d8820 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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` diff --git a/docs/module-graph.md b/docs/module-graph.md index 8419646dca..5f77800b48 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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) | diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index de0cf52845..39aabe4da4 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.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 packages/ui/tui/README.md -README.md: 5199e75d732f384497b28518920cc2c775c600c2 -README.zh.md: 02d4431fdab575b5596152c985b72594a948b081 +README.md: 78d56a6cacd040fdd32b73f779bab3fb4c77fcce +README.zh.md: c403605bb13d252eec00a2b0ebafb5f953c884f8 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 5199e75d73..78d56a6cac 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -32,11 +32,11 @@ 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, 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. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 02d4431fda..c403605bb1 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -32,11 +32,11 @@ Footer 将会话报告的用量汇总为 `↑`;任 `/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`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index a68fe7968a..135774a846 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -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:^", diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index 18f7944fc2..f2173862ae 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -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, - ): Promise => { + 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 => { + 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 => { + 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 => { + 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(records.length) + let cursor = 0 + const worker = async (): Promise => { + 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 => { @@ -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 => { + // 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') }) }, } diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 6867a30240..d427b906e1 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -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, 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))) diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index 8b07629af8..28ec85edb6 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -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, diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index a62b695b72..3f10d6cfe3 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -69,6 +69,8 @@ export interface TuiHarnessOptions { sessionPersistence?: { list(): Promise load?(id: ReturnType): 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 undefined, + locate: (meta: SessionHeader) => persistence.locate?.(meta), create: () => Promise.resolve(), append: () => Promise.resolve(), load: persistence.load === undefined diff --git a/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt index 63ca211c89..9ab0788461 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt @@ -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| " " diff --git a/packages/ui/tui/tests/snapshots/resume-sessions-loading.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions-loading.expected.txt new file mode 100644 index 0000000000..e44621ad60 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/resume-sessions-loading.expected.txt @@ -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| " " diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index 46452aebdb..6f3e87ed43 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -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| " " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 21696153a2..535a992a87 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -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() + // 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 }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 4ecf78d54a..a6bfa8e978 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -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() + 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() + 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() + 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>() + 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) }) diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index eddddcc704..06a36adc28 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e27760b0d..cc4c582a93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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