diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml new file mode 100644 index 0000000000..8cc4d64238 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.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/feature/2026-07-24-tui-question-dialog-multiline.md +2026-07-24-tui-question-dialog-multiline.md: fc6e9bceeee4abc46a69a23124d09fcd4f3c7224 +2026-07-24-tui-question-dialog-multiline.zh.md: a56821921bad1016009687bde63eae5f4d893cdf diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md new file mode 100644 index 0000000000..fc6e9bceee --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md @@ -0,0 +1,41 @@ +# Agent Note: TUI QuestionDialog renders options across multiple lines + +Status: implemented + +English | [中文](2026-07-24-tui-question-dialog-multiline.zh.md) + +## Problem + +`ctx.userInteraction.ask()` must keep question text, supporting `detail`, option labels, descriptions, validation, and controls readable inside configured width and height bounds. The question panel also belongs directly above the editor: placing it at the terminal edge separates the pending decision from both the transcript that prompted it and the input that follows it. + +## Decision + +The TUI renders a pending question as an inline modal between the transcript/status area and the editor while retaining the shared FIFO with model and plugin overlays: + +- `InlineModalComponent` applies `questionDialogWidth` and `questionDialogMaxHeight` inside the normal component flow. The effective question height is additionally clamped to the current viewport after reserving the editor, so the editor remains below the question during resize. +- `renderOptionBlock` wraps each label beneath its cursor/number prefix and renders the muted description on separately wrapped, equally indented lines. The progress header, question, custom-answer hint, validation text, and final rows are width-bounded as well; the final ellipsis clamp is only a safety boundary for prefixes or other indivisible content. The explicit `↑ N lines hidden` fallback is reserved for a viewport below the configured minimum, where the whole semantic layout cannot fit. +- When question text or `detail` exceeds the header allocation, the header becomes a paged line viewport with its own `… lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down traverse both line viewports: forward navigation exhausts the header/detail pages before entering oversized selected-option pages, and backward navigation reverses that order. This keeps plan-review detail reachable rather than leaving it behind the height clamp. +- The option-line budget subtracts padding, header, position, and footer rows before `windowBlocks` runs. The window obeys both `maxQuestionOptions` and the remaining row budget, keeps the selected option visible, and renders omitted options as `↑ N more` / `↓ N more` markers. If fixed chrome would leave fewer than four option rows, the compact header becomes the line pager so selected content, paging status, and both option markers still fit. +- When one selected block exceeds its allocation, it becomes a line viewport with a `lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down expose every wrapped line without allowing the block to hide the option markers, validation, or controls. + +Package tests pin count and height bounds, header and selected-block paging order, narrow-width wrapping, selection behavior, and placement relative to retained editor input. Semantic TUI snapshots pin the assembled terminal layout, header/detail and selected-option page transitions, and validation state. + +## Alternatives considered + +**Ellipsis-only horizontal truncation.** Keeping one option per row would signal lost text without making the description readable and would not address vertical bounds. The implementation wraps readable content and retains an ellipsis only as a final safety boundary. + +**Wrap the combined label and description.** A composite row couples their widths, so either side can starve the other. Separate lines keep both widths predictable. + +**Keep the question as a bottom-edge overlay.** A terminal-edge anchor can place the panel after the editor or cover lower chrome, depending on transcript and viewport height. The inline modal preserves ordering while the modal manager retains focus and FIFO ownership. + +**Push the bounds into pi-tui.** Generic overlay slicing cannot identify option boundaries, selected content, controls, or the inline editor relationship. The owning dialog therefore applies semantic count, row, and paging rules. + +**Use only the option-count cap.** `maxQuestionOptions` remains a public count bound, but it cannot contain wrapped blocks by itself. The dialog enforces the count and row bounds together. + +## Consequences + +- Descriptions consume additional rows, so fewer options can be visible than `maxQuestionOptions`; markers state the omitted option counts. +- Long question text and plan-review detail remain reachable inside a height-bounded panel, at the cost of sharing Page Up and Page Down with selected-option paging. +- An oversized selected block reserves one status row and requires Page Up or Page Down to read beyond the current line page. +- The inline question can displace older transcript rows from a short viewport. Below the configured minimum height, the final fallback can collapse upper rows behind an explicit hidden-line marker so the input controls and editor remain available. +- The model-facing schema, selected labels, abort/cancel behavior, and ACP elicitation path are unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md new file mode 100644 index 0000000000..a56821921b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md @@ -0,0 +1,41 @@ +# Agent Note: TUI QuestionDialog 以多行方式渲染选项 + +Status: implemented + +[English](2026-07-24-tui-question-dialog-multiline.md) | 中文 + +## 问题 + +`ctx.userInteraction.ask()` 必须确保问题正文、`detail` 补充内容、选项标签、描述、校验信息和控件在已配置的宽度与高度边界内均可读。问题面板也直接位于编辑器上方:若将其置于终端边缘,待处理决策就会同时脱离触发该决策的 transcript(文本记录)和后续输入。 + +## 决策 + +TUI 将待处理问题渲染为位于 transcript/状态区域与编辑器之间的内联模态框,同时仍与模型浮层和插件浮层共享 FIFO: + +- `InlineModalComponent` 在正常组件流内应用 `questionDialogWidth` 和 `questionDialogMaxHeight`。系统在为编辑器预留空间后,还会根据当前视口限制问题的实际高度,因此调整窗口大小时,编辑器仍位于问题下方。 +- `renderOptionBlock` 将每个标签换行到光标/编号前缀下方,并在另行换行且缩进相同的行上渲染弱化的描述。进度标题、问题、自定义答案提示、校验文本和末尾行也受宽度边界约束;最终的省略号截断仅作为前缀或其他不可拆分内容的安全边界。明确的 `↑ N lines hidden` 回退仅用于低于已配置最小值、无法容纳完整语义布局的视口。 +- 当问题正文或 `detail` 超出头部分配的空间时,头部会成为带有独立 `… lines A-B/N • PgUp/PgDn` 状态行的分页行视口。Page Up 和 Page Down 会遍历这两个行视口:向前导航先翻完问题正文/`detail` 页面,再进入超大选中选项页面;向后导航则采用相反顺序。这样可确保计划评审的 `detail` 内容始终可达,而不会被高度边界挡住。 +- 在 `windowBlocks` 运行前,选项行预算会扣除内边距、标题行、位置行和页脚行。窗口同时遵守 `maxQuestionOptions` 和剩余行预算,保持选中项可见,并将省略的选项渲染为 `↑ N more`/`↓ N more` 标记。若固定界面元素会使选项行少于四行,紧凑头部会转为行分页器,从而容纳选中内容、分页状态和上下两个选项标记。 +- 当一个选中块超出分配空间时,它会成为带有 `lines A-B/N • PgUp/PgDn` 状态行的行视口。Page Up 和 Page Down 可展示每一行已换行内容,同时防止该块遮住选项标记、校验信息或控件。 + +包(package)测试固定数量和高度边界、头部与选中块的分页顺序、窄宽度换行、选择行为,以及问题相对于保留的编辑器输入的位置。语义 TUI 快照固定组装后的终端布局、头部/详情与选中选项的分页转换,以及校验状态。 + +## 备选方案 + +**仅用省略号进行横向截断。** 保持每个选项占一行,只能提示文本有所丢失,无法使描述变得可读,也无法处理纵向边界。该实现会对可读内容换行,仅将省略号保留为最终安全边界。 + +**将标签与描述合并后换行。** 组合行会将两者的宽度耦合在一起,任一方都可能挤占另一方的空间。分行渲染可使二者的宽度保持可预测。 + +**将问题保留为终端底边浮层。** 根据 transcript 和视口高度,锚定在终端边缘的面板可能出现在编辑器之后,也可能遮盖下方界面元素。内联模态框可保留顺序,同时由模态管理器继续负责焦点和 FIFO 所有权。 + +**将边界处理下推至 pi-tui。** 通用浮层切片无法识别选项边界、选中内容、控件或内联编辑器关系。因此,负责该语义的对话框会应用数量、行数和分页规则。 + +**仅使用选项数量上限。** `maxQuestionOptions` 仍是公开的数量边界,但仅靠它无法容纳已换行的块。对话框会同时执行数量边界和行数边界。 + +## 后果 + +- 描述会占用额外行,因此可见选项数可能少于 `maxQuestionOptions`;标记会说明省略的选项数量。 +- 较长的问题正文和计划评审 `detail` 在受高度约束的面板内仍然可达,代价是 Page Up 和 Page Down 需要与选中选项分页共用。 +- 超出空间的选中块会预留一行状态信息;若要阅读当前页面之外的行,必须使用 Page Up 或 Page Down。 +- 在较矮的视口内,内联问题可能将较早的 transcript 行挤出可见区域。低于已配置最小高度时,最终回退可能将上部行折叠到明确的隐藏行标记之后,从而让输入控件和编辑器仍然可用。 +- 面向模型的 schema、选中的标签、中止/取消行为,以及 ACP(Agent Client Protocol)的 elicitation 路径均保持不变。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 707d7b72a0..bdf0d4f7d1 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -6,10 +6,10 @@ // layers, per-plugin CSS injection, and a rendered journey reaching chat // content from the keyless FixtureApiClient transport. // -// Behavior assertions do NOT belong here: component and wiring behavior is -// pinned by the per-package suites (SlotTestRuntime benches over src), which -// this smoke's plugin set cannot influence — bundling, module-table -// resolution, and boot layering are the only failure modes left to it. +// Component behavior remains owned by per-package suites (SlotTestRuntime +// benches over src). This smoke additionally pins the resident approval +// fixture's cross-plugin projection because only the built connection/runtime/ +// workspace graph can prove that transport-to-row path end to end. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -105,8 +105,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) await within(tree).findByText('4 sessions') + // The resident approval fixture proves the assembled workspace plugin + // distinguishes a blocked running session from an ordinarily busy one. + const waitingTitle = await within(tree).findByText('Fixture 历史会话') + const waitingRow = waitingTitle.closest('[role="treeitem"]') + if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row') + expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() + expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() + within(waitingRow).getByText('Waiting for approval') + // Opening a session reaches chat content through the fixture transport. - fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + fireEvent.click(waitingTitle) await waitFor(() => { expect(document.querySelector('[data-sample="bash"]')).not.toBeNull() }, { timeout: 10_000 }) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8e76c1c8de..b518f37876 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:244`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 09e17a4371..da6ea1a597 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: e1dbe7d4d5992b6b5b029fddfc9d9857ccae7443 -README.zh.md: fd82ad65f8e903a6f7106e8b8ff8eccbf1435957 +README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18 +README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e1dbe7d4d5..78572ba0ab 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index fd82ad65f8..7708e980e2 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 6c5f1735e3..c1f5d5df03 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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/client/ui-sidebar/README.md -README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9 -README.zh.md: 8c8545a5d7d8cb4d58772abf867d7ee82c31bf1d +README.md: 19c2d1033de4475816249aa8429f4a589eeb6481 +README.zh.md: b8c154586570cf1b9fd4bf776bc09b36ab5ee7d2 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 93a1f15a58..19c2d1033d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired. +- **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — done/error notification sources remain deferred. - **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 8c8545a5d7..b8c1545865 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -22,6 +22,6 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 -- **状态点只有两种实时数据状态(running/none)**:done/error/amber 的数据源将随 P-II 审批与通知功能一并提供;四色原语已接入。 +- **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:done/error 的通知数据源仍暂缓实现。 - **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 4023983d6d..374efd0f58 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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/client/ui-workspace/README.md -README.md: 2670bdfa2fb1a223bf0c0ea65fbacc1cfb30c607 -README.zh.md: 1e68cfb1a94c240c32949059ca6a3c6adc25208b +README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469 +README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 2670bdfa2f..17105f9d70 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -12,6 +12,8 @@ Workspace and Session hover cards copy the value their row clips: activating a W The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. +Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`. + Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state. @@ -28,4 +30,5 @@ None; this package neither assembles nor sends a provider request. - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. - **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions. +- **Approval waiting is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 1e68cfb1a9..b40b946927 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -12,6 +12,8 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 +Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。 + 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 @@ -28,4 +30,5 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork, - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 - **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。 +- **待审批状态不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 3368f7b3e7..e64127a70d 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -46,6 +46,7 @@ export const zh = { 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', 'status.idle': '空闲', + 'status.waitingApproval': '等待审批', 'hover.created': '创建于 {time}', 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', @@ -103,6 +104,7 @@ export const en = { 'actions.newSession.aria': 'New session in {name}', 'status.running': 'Running', 'status.idle': 'Idle', + 'status.waitingApproval': 'Waiting for approval', 'hover.created': 'Created {time}', 'hover.copied': 'Copied', 'date.ymd': '{y}-{m}-{d}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index ccbbabb9ce..612eb3e306 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -124,6 +124,15 @@ color: var(--dsw-alias-label-tertiary); } +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + .folderActive { color: var(--dsw-alias-state-business-primary); diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index ab92e671ea..e961919498 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -12,6 +12,7 @@ import { IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import { relativeTime } from '../tree.ts' @@ -165,16 +166,16 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { ) } -/** - * One top-level 34px session row with running dot and relative time. - * @param props.node - derived session node. - * @param props.currentId - selected session id (row highlight). - * @param props.now - epoch ms for relative-time formatting. - * @param props.onOpen - open a session by id. - * @returns the session row. - */ -/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */ +/** Session status presentation; approval waiting outranks the underlying running state. */ +function sessionStatus(node: SessionNode, t: RowTranslate): { state: StateDotState; label: string } { + if (node.waitingApproval) return { state: 'warning', label: t('status.waitingApproval') } + if (node.running) return { state: 'ongoing', label: t('status.running') } + return { state: 'done', label: t('status.idle') } +} + +/** Hover-card body: full title, relative time, and approval/running/idle status. */ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) { + const status = sessionStatus(node, t) return (
{displayTitle(node, t)}
@@ -182,8 +183,8 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; before the first prompt. */} {!node.blank &&
{hoverTimeLabel(node.updatedAt, now, t)}
}
- - {node.running ? t('status.running') : t('status.idle')} + + {status.label}
) @@ -248,6 +249,20 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } +/** + * One top-level 34px session row: status dot (approval waiting outranks + * running), title, relative time, and the row actions menu. + * @param props.node - derived session node. + * @param props.currentId - selected session id (row highlight). + * @param props.now - epoch ms for relative-time formatting. + * @param props.onOpen - open a session by id. + * @param props.onRename - open the session rename dialog (id + current title). + * @param props.onFork - fork a session at its last completed turn. + * @param props.onArchive - archive a session by id. + * @param props.drag - optional draggable-row wiring. + * @param props.t - the browser root's locale seat. + * @returns the session row. + */ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: { node: SessionNode currentId: string | undefined @@ -266,6 +281,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const row = node const title = displayTitle(node, t) const selected = node.id === currentId + const status = sessionStatus(node, t) const [menuOpen, setMenuOpen] = useState(false) // Archive replaces the former Delete placeholder: it hides the row through // the registry-global archive set and never touches the session log, so it @@ -310,7 +326,14 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork drag.drop(rowHalf(e)) }} > - {row.running && } + + {status.state !== 'done' && ( + <> + + {status.label} + + )} + {title} {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 6010a1030e..8c72608eaf 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -20,6 +20,8 @@ export interface SessionNode { title: string /** The provisional blank session (renderer shows the localized New Session title). */ blank: boolean + /** The runtime Session list reports a pending approval request for this Session. */ + waitingApproval: boolean running: boolean updatedAt: number } @@ -169,6 +171,7 @@ function sessionNode(s: SessionSummary): SessionNode { id: s.id, title: sessionTitle(s), blank: s.blank, + waitingApproval: s.waitingApproval, running: s.running, updatedAt: s.updatedAt, } diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 7bef820d4a..1fdfbed0b9 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -96,7 +96,7 @@ describe('workspace browser rows', () => { it('renders and opens a selected running Session row', () => { const node: SessionNode = { - id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0, + id: sid('session'), title: 'Session', blank: false, waitingApproval: false, running: true, updatedAt: 0, } const onOpen = vi.fn() render( @@ -180,7 +180,7 @@ describe('workspace browser rows', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0, + id: sid('s-blank'), title: 'ignored', blank: true, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -206,7 +206,7 @@ describe('workspace browser rows', () => { const onFork = vi.fn() const onArchive = vi.fn() const node: SessionNode = { - id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'One', blank: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -234,11 +234,12 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('menu')).toBeNull() }) + it('shows the hover card after the dwell and suppresses it while the row menu is open', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0, + id: sid('s1'), title: 'Hovered', blank: false, waitingApproval: false, running: true, updatedAt: 0, } render() @@ -248,7 +249,7 @@ describe('workspace browser rows', () => { // Card body: full title + relative time + running status. expect(screen.getAllByText('Hovered')).toHaveLength(2) expect(screen.getByText('1分钟前')).toBeTruthy() - expect(screen.getByText('进行中')).toBeTruthy() + expect(screen.getAllByText('进行中')).toHaveLength(2) fireEvent.pointerLeave(wrapper) // Menu open (disabled=true) suppresses the card for the same hover. fireEvent.click(screen.getByRole('button', { name: '会话“Hovered”的操作' })) @@ -260,11 +261,38 @@ describe('workspace browser rows', () => { } }) + it('shows approval waiting as warning ahead of the running state', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('approval'), title: 'Needs approval', blank: false, + waitingApproval: true, running: true, updatedAt: 0, + } + const view = render() + const row = screen.getByRole('treeitem') + expect(row.querySelector('[data-state="warning"]')).toBeTruthy() + expect(row.querySelector('[data-state="ongoing"]')).toBeNull() + expect(screen.getByText('等待审批')).toBeTruthy() + + view.rerender() + expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy() + + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByText('等待审批')).toHaveLength(2) + expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('idle hover card shows the Idle status line', () => { vi.useFakeTimers() try { const node: SessionNode = { - id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Quiet', blank: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -279,7 +307,7 @@ describe('workspace browser rows', () => { it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { - id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0, + id: sid('s1'), title: 'Drag me', blank: false, waitingApproval: false, running: false, updatedAt: 0, } const inactive = dragProps() const { rerender } = render( diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 2d0055ce7f..249c3629a2 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -38,6 +38,14 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) + it('projects approval-waiting state into grouped and flat rows', () => { + const awaiting = { ...summary('awaiting', 10), waitingApproval: true, running: true } + const sessions = list(awaiting) + const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], noArchive, view(['project'])) + expect(grouped[0]!.sessions[0]).toMatchObject({ waitingApproval: true, running: true }) + expect(deriveFlat(sessions, noArchive)[0]).toMatchObject({ waitingApproval: true, running: true }) + }) + it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY])) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index b42b9922b3..de0cf52845 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: d80951e586e6ec199a9340caf7ab5c2884ae8ca4 -README.zh.md: 4e5c0d1f9c01549eba4f0577cfb2acece3d82dcb +README.md: 5199e75d732f384497b28518920cc2c775c600c2 +README.zh.md: 02d4431fdab575b5596152c985b72594a948b081 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index d80951e586..5199e75d73 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects ` After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. -The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. +The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions inline between the transcript/status area and the editor. The question panel shows progress, numbered options, wrapped labels, and separately indented descriptions; it obeys both `maxQuestionOptions` and `questionDialogMaxHeight`, marks hidden options with `↑ N more` / `↓ N more`, and uses Page Up / Page Down to page long question/detail content before an individually oversized selected block while keeping the editor visible. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. @@ -51,11 +51,11 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY | `showReasoning` | `true` | Render reasoning blocks | | `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | | `maxDiffEditLength` | `1000` | Maximum added and removed lines explored for an exact diff before whole-side fallback | -| `maxQuestionOptions` | `8` | Visible options in a question panel | +| `maxQuestionOptions` | `8` | Maximum option blocks visible at once; the row bound may reduce this further | | `maxModelOptions` | `8` | Visible models in the model selector | | `maxResumeOptions` | `8` | Visible sessions in the resume selector | | `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | -| `questionDialogMaxHeight` | `20` | Question-panel maximum rows | +| `questionDialogMaxHeight` | `20` | Maximum question-panel rows, further bounded to retain the editor | | `modelDialogWidth` | `76` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | | `detailsDialogWidth` | `72` | Transcript-details selector width in columns | diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 4e5c0d1f9c..02d4431fda 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 -TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 +TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在 transcript/状态区域与编辑器之间内联展示 `ctx.userInteraction` 问题。问题面板会显示进度、编号选项、换行标签和另行缩进的描述;它同时遵守 `maxQuestionOptions` 和 `questionDialogMaxHeight`,用 `↑ N more`/`↓ N more` 标记隐藏选项,并在保持编辑器可见的同时,通过 Page Up 和 Page Down 先分页浏览过长的问题/详情内容,再分页浏览单个超大的选中块。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。 @@ -51,11 +51,11 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `showReasoning` | `true` | 渲染 reasoning 块 | | `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 | | `maxDiffEditLength` | `1000` | 回退到整侧展示前,精确 diff 最多探索的新增与删除行总数 | -| `maxQuestionOptions` | `8` | 问题面板中可见的选项数 | +| `maxQuestionOptions` | `8` | 一次最多可见的选项块数;行数边界可能进一步减少可见数量 | | `maxModelOptions` | `8` | 模型选择器中可见的模型数 | | `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 | | `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 | -| `questionDialogMaxHeight` | `20` | 问题面板最大行数 | +| `questionDialogMaxHeight` | `20` | 问题面板最大行数,会进一步受限以保留编辑器 | | `modelDialogWidth` | `76` | 模型选择器宽度(列数) | | `modelDialogMaxHeight` | `20` | 模型选择器最大行数 | | `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) | diff --git a/packages/ui/tui/src/chat/questions.ts b/packages/ui/tui/src/chat/questions.ts index e5f2c8b806..5d96282860 100644 --- a/packages/ui/tui/src/chat/questions.ts +++ b/packages/ui/tui/src/chat/questions.ts @@ -29,7 +29,10 @@ interface PendingQuestion { } /** Collaborators the question queue needs from the chat channel. */ -export type QuestionQueueDeps = ChatChannelDeps +export interface QuestionQueueDeps extends ChatChannelDeps { + /** Current row budget after reserving the editor. */ + questionMaxHeight(): number +} /** Ask-user-question controller for one chat channel. */ export interface QuestionQueue { @@ -85,6 +88,7 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { pending.request.questions.length, pending.request.questions.length - pending.answers.length, resolved.maxQuestionOptions, + () => deps.questionMaxHeight(), palette, (selection) => { pending.overlay = undefined @@ -102,10 +106,8 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { options: { width: resolved.questionDialogWidth, maxHeight: resolved.questionDialogMaxHeight, - anchor: 'bottom-left', - margin: { bottom: 1 }, }, - }) + }, 'inline') pending.overlay = session void session.closed.then((result) => { if (pending.overlay !== session) return diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index c4527726c1..6867a30240 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -822,10 +822,18 @@ export class ResumePicker implements Component, Focusable { } } -/** Bottom-anchored dialog for one user question with option or custom-answer modes. */ +interface SelectedBlockPage { + offset: number + size: number + maxOffset: number +} + +/** Inline dialog for one user question with option or custom-answer modes. */ export class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() + private headerPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } + private selectedBlockPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } private mode: 'options' | 'custom' private error = '' private readonly input = new Input() @@ -838,6 +846,7 @@ export class QuestionDialog implements Component, Focusable { private readonly total: number, private readonly unanswered: number, private readonly maxVisible: number, + private readonly maxHeight: () => number, private readonly palette: Palette, private readonly done: (selection: QuestionSelection) => void, private readonly cancel: () => void, @@ -861,6 +870,14 @@ export class QuestionDialog implements Component, Focusable { handleInput(data: string): void { this.invalidate() + if (matchesKey(data, Key.pageUp)) { + this.pageBackward() + return + } + if (matchesKey(data, Key.pageDown)) { + this.pageForward() + return + } if (this.mode === 'custom') { this.input.focused = this.focused this.input.handleInput(data) @@ -868,8 +885,10 @@ export class QuestionDialog implements Component, Focusable { } const options = this.options if (matchesKey(data, Key.up)) { + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 } else if (matchesKey(data, Key.down)) { + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 } else if (matchesKey(data, Key.space) && this.question.multiSelect) { if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) @@ -886,6 +905,7 @@ export class QuestionDialog implements Component, Focusable { this.done({ selected, ...(custom === '' ? {} : { custom }) }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.error = '' } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { this.cancel() @@ -911,80 +931,349 @@ export class QuestionDialog implements Component, Focusable { .filter((label): label is string => label !== undefined) } + /** Page backward through an oversized option, then through question detail. */ + private pageBackward(): void { + if (this.mode === 'options' && this.selectedBlockPage.offset > 0) { + this.selectedBlockPage = { + ...this.selectedBlockPage, + offset: Math.max(0, this.selectedBlockPage.offset - this.selectedBlockPage.size), + } + return + } + this.headerPage = { + ...this.headerPage, + offset: Math.max(0, this.headerPage.offset - this.headerPage.size), + } + } + + /** Page forward through question detail, then through an oversized option. */ + private pageForward(): void { + if (this.headerPage.offset < this.headerPage.maxOffset) { + this.headerPage = { + ...this.headerPage, + offset: Math.min( + this.headerPage.maxOffset, + this.headerPage.offset + this.headerPage.size, + ), + } + return + } + if (this.mode === 'custom') return + this.selectedBlockPage = { + ...this.selectedBlockPage, + offset: Math.min( + this.selectedBlockPage.maxOffset, + this.selectedBlockPage.offset + this.selectedBlockPage.size, + ), + } + } + render(width: number): string[] { this.input.focused = this.focused - const innerWidth = Math.max(1, width - 4) + const horizontalPadding = Math.min(2, Math.max(0, Math.floor((width - 1) / 2))) + const innerWidth = Math.max(1, width - horizontalPadding * 2) const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` - const lines = [ - this.palette.dim(header), - ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), + const questionLines = wrapTextWithAnsi( + this.palette.text(displayText(this.question.question)), + innerWidth, + ) + const contentLines = [...questionLines] + const headerLines: string[] = [ + ...wrapTextWithAnsi(this.palette.dim(header), innerWidth), + ...questionLines, ] - const push = (line: string): void => { lines.push(line) } // Supporting detail (e.g. the full plan under review) renders between the // question and the answer surface, kept out of option labels. if (this.question.detail !== undefined) { - push('') - for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) - } - push('') - if (this.mode === 'custom') { - for (const line of this.input.render(innerWidth)) push(line) - const controls = [ - ...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []), - 'Enter submit', - this.options.length > 0 ? 'Esc options' : 'Esc cancel', - ] - push(this.palette.dim(controls.join(' • '))) - } else { - const options = this.options - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - options.length - this.maxVisible, - )) - const end = Math.min(options.length, start + this.maxVisible) - const optionRows = options.slice(start, end).map((option, offset) => { - const index = start + offset - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - }) - const descriptionColumn = Math.min( - Math.max(...optionRows.map(row => visibleWidth(row))) + 2, - Math.max(1, Math.floor(innerWidth * 0.55)), - ) - for (let index = start; index < end; index += 1) { - // `index < end <= options.length`; the options array is borrowed immutably for this dialog. - const option = options[index] as NonNullable[number] - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - const leftStyled = index === this.selectedIndex - ? this.palette.bold(this.palette.accent(left)) - : left - const description = option.description === undefined - ? '' - : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.dim(displayText(option.description))}` - push(`${leftStyled}${description}`) + headerLines.push('') + contentLines.push('') + for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) { + headerLines.push(line) + contentLines.push(line) } - if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) + } + headerLines.push('') + + const customControls = [ + ...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []), + 'Enter submit', + this.options.length > 0 ? 'Esc options' : 'Esc cancel', + ] + const customHint = this.palette.dim(customControls.join(' • ')) + const footerLines: string[] = [] + if (this.mode === 'custom') { + for (const line of this.input.render(innerWidth)) footerLines.push(line) + for (const line of wrapTextWithAnsi(customHint, innerWidth)) footerLines.push(line) + } else { const controls = [ 'Tab custom answer', - ...(options.length > 1 ? ['↑/↓ navigate'] : []), + ...(this.options.length > 1 ? ['↑/↓ navigate'] : []), ...(this.question.multiSelect ? ['Space toggle'] : []), 'Enter submit', 'Esc interrupt', ] const hint = this.palette.dim(controls.join(' • ')) - for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) + for (const line of wrapTextWithAnsi(hint, innerWidth)) footerLines.push(line) } if (this.error) { - for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) + for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) footerLines.push(line) } - return ['', ...lines, ''].map((line) => { - const clipped = truncateToWidth(line, innerWidth, '') - return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` + const positionLines = this.mode === 'options' && this.options.length > this.maxVisible + ? [this.palette.dim(`${this.selectedIndex + 1}/${this.options.length}`)] + : [] + + // Options receive only the rows left after fixed chrome and outer padding. + // The final height window handles fixed chrome that cannot fit even alone. + const paddingRows = 2 + const maxHeight = this.maxHeight() + const availableForOptions = Math.max( + this.mode === 'options' ? 4 : 1, + maxHeight - paddingRows - headerLines.length - positionLines.length - footerLines.length, + ) + + const body: string[] = [...headerLines] + const optionLines: string[] = [] + if (this.mode === 'custom') { + for (const line of footerLines) body.push(line) + } else { + const optionBlocks = this.options.map((option, index) => this.renderOptionBlock(option, index, innerWidth)) + const { visibleBlocks, hiddenBefore, hiddenAfter } = this.windowBlocks(optionBlocks, availableForOptions, innerWidth) + if (hiddenBefore > 0) optionLines.push(this.palette.dim(`↑ ${hiddenBefore} more`)) + for (const block of visibleBlocks) { + for (const line of block) optionLines.push(line) + } + if (hiddenAfter > 0) optionLines.push(this.palette.dim(`↓ ${hiddenAfter} more`)) + for (const line of optionLines) body.push(line) + for (const line of positionLines) body.push(line) + for (const line of footerLines) body.push(line) + } + + const rows = ['', ...body, ''] + let visibleRows = rows + if (rows.length <= maxHeight) this.headerPage = { offset: 0, size: 1, maxOffset: 0 } + if (rows.length > maxHeight && this.mode === 'options' && maxHeight >= 6) { + const headerBudget = Math.max( + 0, + maxHeight - optionLines.length - (this.error === '' ? 1 : 2), + ) + const compactFooter = [ + ...this.error === '' + ? [] + : [truncateToWidth(this.palette.error(`Error: ${this.error}`), innerWidth, '…')], + this.compactOptionControls( + innerWidth, + headerBudget === 1 && contentLines.length > headerBudget, + ), + ] + const compactHeader = this.compactQuestionHeader(contentLines, headerBudget, innerWidth) + visibleRows = [...compactHeader, ...optionLines, ...compactFooter] + } else if (rows.length > maxHeight && this.mode === 'custom' && maxHeight >= 2) { + const compactFooterSource = [ + ...this.input.render(innerWidth), + this.compactCustomControls(innerWidth), + ...this.error === '' + ? [] + : [truncateToWidth(this.palette.error(this.error), innerWidth, '…')], + ] + const footerBudget = Math.max(1, maxHeight - 1) + const compactFooter = compactFooterSource.length <= footerBudget + ? compactFooterSource + : footerBudget === 1 + ? compactFooterSource.slice(0, 1) + : [ + ...compactFooterSource.slice(0, 1), + ...compactFooterSource.slice(-(footerBudget - 1)), + ] + const compactHeader = this.compactQuestionHeader( + contentLines, + Math.max(0, maxHeight - compactFooter.length), + innerWidth, + ) + visibleRows = [...compactHeader, ...compactFooter] + } + if (visibleRows.length > maxHeight) { + visibleRows = maxHeight === 1 + ? [this.palette.dim(`↑ ${visibleRows.length} lines hidden`)] + : [ + this.palette.dim(`↑ ${visibleRows.length - maxHeight + 1} lines hidden`), + ...visibleRows.slice(-(maxHeight - 1)), + ] + } + return visibleRows.map((line) => { + const bounded = truncateToWidth(line, innerWidth, '…') + const pad = ' '.repeat(Math.max(0, innerWidth - visibleWidth(bounded))) + const outerPad = ' '.repeat(horizontalPadding) + return `${outerPad}${bounded}${pad}${outerPad}` }) } + + /** Render one option as wrapped label and indented description lines. */ + private renderOptionBlock( + option: NonNullable[number], + index: number, + innerWidth: number, + ): string[] { + const cursor = index === this.selectedIndex ? '›' : ' ' + const number = `${index + 1}. ` + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + const labelPrefixPlain = ` ${cursor} ${number}${mark}` + const labelPrefixWidth = visibleWidth(labelPrefixPlain) + const labelBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) + const labelLines = wrapTextWithAnsi(displayText(option.label), labelBodyWidth) + const continuation = ' '.repeat(labelPrefixWidth) + const lines: string[] = [] + for (const [lineIndex, labelLine] of labelLines.entries()) { + const prefix = lineIndex === 0 ? labelPrefixPlain : continuation + const composed = `${prefix}${labelLine}` + lines.push(index === this.selectedIndex ? this.palette.bold(this.palette.accent(composed)) : composed) + } + if (option.description !== undefined) { + const descIndent = ' '.repeat(labelPrefixWidth) + const descBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) + const descLines = wrapTextWithAnsi(displayText(option.description), descBodyWidth) + for (const descLine of descLines) lines.push(`${descIndent}${this.palette.dim(descLine)}`) + } + return lines + } + + /** Keep the question visible when fixed chrome must be compacted. */ + private compactQuestionHeader( + contentLines: readonly string[], + budget: number, + innerWidth: number, + ): string[] { + if (budget <= 0) return [] + if (contentLines.length <= budget) { + this.headerPage = { offset: 0, size: 1, maxOffset: 0 } + return [...contentLines] + } + const pageSize = Math.max(1, budget - 1) + const maxOffset = Math.max(0, contentLines.length - pageSize) + const offset = Math.min(this.headerPage.offset, maxOffset) + this.headerPage = { offset, size: pageSize, maxOffset } + const keptLines = contentLines.slice(offset, offset + pageSize) + if (budget === 1) { + // A page is non-empty because pageSize is one and offset is clamped inside contentLines. + return [keptLines[0] as string] + } + return [ + ...keptLines, + this.pagerStatus(offset + 1, offset + keptLines.length, contentLines.length, innerWidth), + ] + } + + /** Keep Page Up / Page Down discoverable when a full pager status cannot fit. */ + private pagerStatus(first: number, last: number, total: number, innerWidth: number): string { + const full = `… lines ${first}-${last}/${total} • PgUp/PgDn` + const compact = `PgUp/PgDn ${first}/${total}` + return this.palette.dim(truncateToWidth( + visibleWidth(full) <= innerWidth ? full : compact, + innerWidth, + '…', + )) + } + + /** Render custom-mode controls on one row when the header must compact. */ + private compactCustomControls(innerWidth: number): string { + const controls = this.options.length > 0 + ? 'Enter submit • Esc options' + : 'Enter submit • Esc cancel' + const fallback = this.options.length > 0 ? '↵ Esc options' : 'Enter Esc cancel' + const line = visibleWidth(controls) <= innerWidth ? controls : fallback + return this.palette.dim(truncateToWidth(line, innerWidth, '…')) + } + + /** Render a one-row option footer that retains every mode-specific control. */ + private compactOptionControls(innerWidth: number, showPager = false): string { + const controls = [ + ...(this.options.length > 1 ? ['↑/↓'] : []), + 'Tab custom', + ...(this.question.multiSelect ? ['Space toggle'] : []), + 'Enter', + 'Esc interrupt', + ...(showPager ? ['PgUp/PgDn'] : []), + ].join(' • ') + const optionNavigation = this.options.length > 1 ? '↑↓ ' : '' + const fallback = showPager + ? `P↑↓ ${optionNavigation}Tab${this.question.multiSelect ? ' S' : ''}↵Esc` + : this.question.multiSelect ? `${optionNavigation}Tab Sp ↵Esc` : `${optionNavigation}Tab ↵ Esc` + const line = visibleWidth(controls) <= innerWidth ? controls : fallback + return this.palette.dim(truncateToWidth(line, innerWidth, '…')) + } + + /** + * Choose option blocks that fit while keeping the selected option visible. + * Omitted blocks are counted at each end for explicit overflow markers. + */ + private windowBlocks( + blocks: readonly string[][], + budget: number, + innerWidth: number, + ): { visibleBlocks: string[][]; hiddenBefore: number; hiddenAfter: number } { + const totalLines = blocks.reduce((sum, block) => sum + block.length, 0) + if (totalLines <= budget && blocks.length <= this.maxVisible) { + return { visibleBlocks: [...blocks], hiddenBefore: 0, hiddenAfter: 0 } + } + // `blocks` is dense and selectedIndex is derived from the same options. + let start = this.selectedIndex + let end = this.selectedIndex + 1 + /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ + let used = blocks[this.selectedIndex]?.length ?? 0 + const markerLines = (before: number, after: number): number => + (before > 0 ? 1 : 0) + (after > 0 ? 1 : 0) + const fits = (nextStart: number, nextEnd: number, nextUsed: number): boolean => + nextEnd - nextStart <= this.maxVisible + && nextUsed + markerLines(nextStart, blocks.length - nextEnd) <= budget + const selectedMarkers = markerLines(start, blocks.length - end) + if (used + selectedMarkers > budget) { + /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ + const selectedBlock = blocks[this.selectedIndex] ?? [] + const hiddenBefore = start + const hiddenAfter = blocks.length - end + const pageSize = budget - selectedMarkers - 1 + const maxOffset = Math.max(0, selectedBlock.length - pageSize) + const offset = Math.min(this.selectedBlockPage.offset, maxOffset) + this.selectedBlockPage = { offset, size: pageSize, maxOffset } + const keptLines = selectedBlock.slice(offset, offset + pageSize) + const first = offset + 1 + const last = offset + keptLines.length + const overflow = this.pagerStatus(first, last, selectedBlock.length, innerWidth) + return { + visibleBlocks: [[...keptLines, overflow]], + hiddenBefore, + hiddenAfter, + } + } + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } + let expanded = true + while (expanded && (start > 0 || end < blocks.length)) { + expanded = false + if (end < blocks.length) { + /* v8 ignore next -- guarded by `end < blocks.length` above. */ + const next = blocks[end]?.length ?? 0 + if (fits(start, end + 1, used + next)) { + used += next + end += 1 + expanded = true + continue + } + } + if (start > 0) { + /* v8 ignore next -- guarded by `start > 0` above. */ + const previous = blocks[start - 1]?.length ?? 0 + if (fits(start - 1, end, used + previous)) { + used += previous + start -= 1 + expanded = true + } + } + } + return { + visibleBlocks: blocks.slice(start, end), + hiddenBefore: start, + hiddenAfter: blocks.length - end, + } + } } diff --git a/packages/ui/tui/src/extension/overlay-manager.ts b/packages/ui/tui/src/extension/overlay-manager.ts index 59d84ff3d5..ab80f49cab 100644 --- a/packages/ui/tui/src/extension/overlay-manager.ts +++ b/packages/ui/tui/src/extension/overlay-manager.ts @@ -12,7 +12,6 @@ import type { TuiExtensionService } from '../index.ts' import type { Component, Focusable, - OverlayHandle, } from '@earendil-works/pi-tui' import type { TuiComponent, @@ -36,14 +35,20 @@ export interface TuiOverlayDriver { theme(): TuiTheme /** Escape text at the terminal display boundary. */ display(value: string): string - /** Mount one guarded component and return its private pi-tui handle. */ - show(component: Component, options: TuiOverlayOptions | undefined): OverlayHandle + /** Mount one guarded modal and return its private focus/lifecycle handle. */ + show(component: Component, options: TuiOverlayOptions | undefined, placement: TuiOverlayPlacement): TuiModalHandle /** Invalidate the mounted UI and request a render. */ invalidate(): void /** Report a contained extension failure. */ reportError(error: unknown): void } +type TuiOverlayPlacement = 'overlay' | 'inline' + +interface TuiModalHandle { + hide(): void +} + interface OverlayEntry { readonly request: TuiOverlayRequest readonly controller: AbortController @@ -51,9 +56,10 @@ interface OverlayEntry { readonly closed: Promise readonly resolveClosed: (outcome: TuiOverlayOutcome) => void readonly session: TuiOverlaySession + readonly placement: TuiOverlayPlacement state: TuiOverlayState component?: GuardedOverlayComponent - handle?: OverlayHandle + handle?: TuiModalHandle removeRequestAbort?: () => void outcome?: TuiOverlayOutcome failing?: boolean @@ -165,11 +171,12 @@ export class TuiOverlayManager { } /** - * Queue one overlay without assigning Cordis ownership. + * Queue one modal without assigning Cordis ownership. * @param request - component factory, constraints, and request signal. + * @param placement - terminal overlay for extensions, or inline for the built-in question panel. * @returns an internal session that can close with an ownership reason. */ - open(request: TuiOverlayRequest): TuiOverlaySession & { + open(request: TuiOverlayRequest, placement: TuiOverlayPlacement = 'overlay'): TuiOverlaySession & { closeWith(reason: Exclude): Promise } { if (!this.accepting) throw new Error('TUI is shutting down') @@ -202,6 +209,7 @@ export class TuiOverlayManager { closed: deferred.promise, resolveClosed: deferred.resolve, session, + placement, state: 'queued', } if (requestSignal?.aborted === true) { @@ -251,7 +259,7 @@ export class TuiOverlayManager { }) entry.component = guarded try { - const handle = this.driver.show(guarded, entry.request.options) + const handle = this.driver.show(guarded, entry.request.options, entry.placement) if (this.active !== entry) { this.hide(handle) return @@ -306,7 +314,7 @@ export class TuiOverlayManager { } } - private hide(handle: OverlayHandle): void { + private hide(handle: TuiModalHandle): void { try { handle.hide() } catch (error) { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index f50894b59d..0ddb73ed9c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -15,6 +15,7 @@ import { ProcessTerminal, matchesKey, visibleWidth, + type Component, type EditorTheme, type SlashCommand, type TerminalColorScheme, @@ -289,6 +290,23 @@ interface FadingStatus { timer: ReturnType } +/** Width/height adapter for a modal component rendered inside the base TUI flow. */ +class InlineModalComponent extends Container { + constructor( + component: Component, + private readonly width: number, + private readonly maxHeight: number, + ) { + super() + this.addChild(component) + } + + override render(width: number): string[] { + const lines = super.render(Math.max(1, Math.min(width, this.width))) + return lines.slice(0, Math.max(1, this.maxHeight)) + } +} + /** Lifecycle handle for a mounted interactive terminal channel. */ export interface TuiController { /** Stop rendering, restore the terminal, and reject pending questions. */ @@ -316,6 +334,7 @@ export function createTuiChat( const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() + const questionContainer = new Container() const inputTemplate = parseTuiPromptTemplate(displayInlineText(resolved.theme.inputPrompt)) const renderInputPrompt = (): string => renderTuiPromptTemplate(inputTemplate, valueName => ctx.tuiPrompt.get(valueName)) const initialInputPrompt = renderInputPrompt() @@ -480,6 +499,7 @@ export function createTuiChat( ui.addChild(todoContainer) ui.addChild(compactionStatusLine) ui.addChild(promptContext) + ui.addChild(questionContainer) ui.addChild(editor) ui.setFocus(editor) const updateTerminalTitle = (): void => { @@ -529,14 +549,32 @@ export function createTuiChat( }), theme: () => extensionTheme, display: displayText, - show: (component, options) => ui.showOverlay(component, options === undefined - ? undefined - : { - ...options, - ...typeof options.margin === 'object' - ? { margin: { ...options.margin } } - : {}, - }), + show: (component, options, placement) => { + if (placement === 'overlay') { + return ui.showOverlay(component, options === undefined + ? undefined + : { + ...options, + ...typeof options.margin === 'object' + ? { margin: { ...options.margin } } + : {}, + }) + } + const modal = new InlineModalComponent( + component, + resolved.questionDialogWidth, + resolved.questionDialogMaxHeight, + ) + questionContainer.clear() + questionContainer.addChild(modal) + ui.setFocus(component) + return { + hide(): void { + questionContainer.clear() + ui.setFocus(editor) + }, + } + }, invalidate: requestRender, reportError: (error) => { const message = errorChain(error) @@ -956,6 +994,14 @@ export function createTuiChat( overlayManager, requestRender, isDisposed, + questionMaxHeight: () => { + const width = runtime.terminal.columns + const editorRows = editor.render(width).length + return Math.max(1, Math.min( + resolved.questionDialogMaxHeight, + runtime.terminal.rows - editorRows, + )) + }, }) const resume = createResumeController({ diff --git a/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt new file mode 100644 index 0000000000..415bedd976 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt @@ -0,0 +1,39 @@ +terminal 56x20 buffer=normal length=25 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=56 viewportRow=13 bufferRow=18 +viewport +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint visible plan tail " +18| " … lines 4-13/13 • PgUp/PgDn " + style 2-28 dim +19| " › 1. [ ] Code Mode " + style 2-20 fg=bright-magenta bold +20| " run_code programs and captured output with " + style 12-53 dim +21| " … lines 1-2/12 • PgUp/PgDn " + style 2-27 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt new file mode 100644 index 0000000000..2969918f17 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt @@ -0,0 +1,39 @@ +terminal 56x20 buffer=normal length=25 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=56 viewportRow=16 bufferRow=21 +viewport +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint visible plan tail " +18| " … lines 4-13/13 • PgUp/PgDn " + style 2-28 dim +19| " detail with complete wrapped detail " + style 12-46 dim +20| " visible tail " + style 12-23 dim +21| " … lines 11-12/12 • PgUp/PgDn " + style 2-29 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt index a81556d42a..6cfef15ead 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt @@ -1,7 +1,7 @@ terminal 56x20 buffer=normal length=20 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=19 bufferRow=19 +cursor hidden column=56 viewportRow=16 bufferRow=16 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -21,20 +21,20 @@ viewport style 18-31 dim style 34-50 dim style 53-55 dim -8| " dsh > " +8| " " +9| " Question 1/1 (1 unanswered) · Confirm " + style 2-38 dim +10| " Continue with this change? " +11| " " +12| " › 1. Proceed " + style 2-14 fg=bright-magenta bold +13| " Apply the proposed change " + style 8-32 dim +14| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +15| " " +16| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -9-11| -12| " " -13| " Question 1/1 (1 unanswered) · Confirm " - style 2-38 dim -14| " Continue with this change? " -15| " " -16| " › 1. Proceed Apply the proposed change " - style 2-13 fg=bright-magenta bold - style 16-40 dim -17| " Tab custom answer • Enter submit • Esc interrupt " - style 2-49 dim -18| " " -19| +17-19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index 14193efdda..ecd949cb8d 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -1,40 +1,40 @@ -terminal 56x20 buffer=normal length=20 base=0 viewport=0 +terminal 56x20 buffer=normal length=25 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=56 viewportRow=17 bufferRow=17 +cursor hidden column=56 viewportRow=17 bufferRow=22 viewport -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 dim -2| " main-session" - style 1-12 dim -3| -4| "Assistant " - style 0-8 fg=bright-magenta bold underline -5| " " -6| " Question 1/3 (3 unanswered) · Coverage " - style 2-39 dim -7| " Which advanced TUI states belong in the required " -8| " matrix? " -9| " " -10| " › 1. [ ] Code Mode run_code programs and capture " - style 2-19 fg=bright-magenta bold - style 25-53 dim -11| " 2. [ ] Workflows phases and parallel agents " - style 25-50 dim -12| " 3. [ ] Cordis tools inspect, mount, and unmount " - style 25-51 dim -13| " 1/4 " - style 2-4 dim -14| " Tab custom answer • ↑/↓ navigate • Space toggle • " - style 2-55 dim -15| " Enter submit • Esc interrupt " +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " … lines 4-12/13 • PgUp/PgDn " + style 2-28 dim +18| " detail with complete wrapped detail " + style 12-46 dim +19| " visible tail " + style 12-23 dim +20| " … lines 11-12/12 • PgUp/PgDn " style 2-29 dim -16| " Select at least one option, or press Tab for a " - style 2-55 fg=red -17| " custom answer. " - style 2-15 fg=red -18| " " -19| +21| " ↓ 3 more " + style 2-9 dim +22| " Error: Select at least one option, or press Tab for… " + style 2-52 fg=red +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 640bd6fed2..4b576099ae 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -1,39 +1,39 @@ -terminal 56x20 buffer=normal length=20 base=0 viewport=0 +terminal 56x20 buffer=normal length=25 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=19 bufferRow=19 +cursor hidden column=56 viewportRow=19 bufferRow=24 viewport -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 dim -2| " main-session" - style 1-12 dim -3| -4| "Assistant " - style 0-8 fg=bright-magenta bold underline 5| "Model wait 0.0s " style 0-14 dim 6| -7| " " -8| " Question 1/3 (3 unanswered) · Coverage " - style 2-39 dim -9| " Which advanced TUI states belong in the required " -10| " matrix? " -11| " " -12| " › 1. [ ] Code Mode run_code programs and capture " - style 2-19 fg=bright-magenta bold - style 25-53 dim -13| " 2. [ ] Workflows phases and parallel agents " - style 25-50 dim -14| " 3. [ ] Cordis tools inspect, mount, and unmount " - style 25-51 dim -15| " 1/4 " - style 2-4 dim -16| " Tab custom answer • ↑/↓ navigate • Space toggle • " - style 2-55 dim -17| " Enter submit • Esc interrupt " - style 2-29 dim -18| " " -19| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Which advanced TUI states belong in the required " +9| " matrix? " +10| " " +11| " Review the complete plan including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint including every required " +18| " … lines 1-10/13 • PgUp/PgDn " + style 2-28 dim +19| " › 1. [ ] Code Mode " + style 2-20 fg=bright-magenta bold +20| " run_code programs and captured output with " + style 12-53 dim +21| " … lines 1-2/12 • PgUp/PgDn " + style 2-27 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index e5a625afc0..5cb2529355 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -1,7 +1,7 @@ -terminal 100x34 buffer=normal length=34 base=0 viewport=0 +terminal 100x34 buffer=normal length=40 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" -cursor hidden column=0 viewportRow=33 bufferRow=33 +cursor hidden column=100 viewportRow=33 bufferRow=39 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -48,15 +48,30 @@ buffer 24| 25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-62 fg=red -26| " " -27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +26-27| +28| "Plan" + style 0-3 fg=bright-magenta bold +29| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" + style 2-2 fg=yellow +30| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +31| " " +32| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 2-90 dim -28| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -29| " " -30| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " - style 2-65 fg=bright-magenta bold - style 67-97 dim -31| " Tab custom answer • Enter submit • Esc interrupt " +33| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +34| " " +35| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 2-66 fg=bright-magenta bold +36| " Unsafe detail \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 8-66 dim +37| " Tab custom answer • Enter submit • Esc interrupt " style 2-49 dim -32| " " -33| +38| " " +39| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 72718cfae1..21696153a2 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -49,6 +49,8 @@ const CHECKPOINTS = [ 'details-selector', 'untrusted-controls', 'question-dialog', + 'question-dialog-detail-paged', + 'question-dialog-paged', 'question-dialog-single-option', 'question-dialog-validation', 'surface-before-compaction', @@ -786,9 +788,13 @@ describe('TUI terminal-state snapshots', () => { id: 'coverage', header: 'Coverage', question: 'Which advanced TUI states belong in the required matrix?', + detail: `Review the complete plan ${'including every required checkpoint '.repeat(12)}visible plan tail`, multiSelect: true, options: [ - { label: 'Code Mode', description: 'run_code programs and captured output' }, + { + label: 'Code Mode', + description: `run_code programs and captured output ${'with complete wrapped detail '.repeat(12)}visible tail`, + }, { label: 'Workflows', description: 'phases and parallel agents' }, { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, { label: 'Compaction', description: 'surface replacement and reflow' }, @@ -803,6 +809,14 @@ describe('TUI terminal-state snapshots', () => { await harness.terminal.waitForFrame(beforeQuestion) await checkpoint('question-dialog', harness.terminal) + await renderAfter(harness, () => { harness.terminal.send('\x1b[6~') }) + await checkpoint('question-dialog-detail-paged', harness.terminal) + + await renderAfter(harness, () => { + for (let page = 0; page < 30; page += 1) harness.terminal.send('\x1b[6~') + }) + await checkpoint('question-dialog-paged', harness.terminal) + await renderAfter(harness, () => { harness.terminal.send('\r') }) await checkpoint('question-dialog-validation', harness.terminal) controller.abort() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 6f7d042848..4ecf78d54a 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -50,6 +50,7 @@ import { disposeTuiTestHarness, type TuiHarnessOptions, } from './harness.ts' +import { HeadlessTerminal } from './headless-terminal.ts' import { TestSessionQueryService } from './session-query.ts' const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = { @@ -5510,6 +5511,54 @@ describe('tool cards and surface replay', () => { }) describe('TUI user-interaction dialogs', () => { + it('limits the visible option window to maxQuestionOptions', async () => { + const result = await setup({ + config: { maxQuestionOptions: 1, questionDialogWidth: 60, questionDialogMaxHeight: 20 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'cap', + question: 'Pick one', + options: [{ label: 'Visible first' }, { label: 'Hidden second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Visible first') + expect(result.terminal.output).not.toContain('Hidden second') + expect(result.terminal.output).toContain('↓ 1 more') + result.terminal.send('\x03') + await rejected + + await dispose(result) + }) + + it('renders a pending question between the transcript and editor', async () => { + const result = await setup({ + config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 }, + }) + result.terminal.send('draft input') + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'placement', + question: 'Pick one', + options: [{ label: 'First' }, { label: 'Second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const render = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + const questionIndex = render.indexOf('Pick one') + const editorIndex = render.indexOf('draft input') + expect(questionIndex).toBeGreaterThanOrEqual(0) + expect(editorIndex).toBeGreaterThan(questionIndex) + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + it('answers single-select, multi-select, custom, and optionless questions', async () => { const result = await setup({ config: { maxQuestionOptions: 1 } }) @@ -5629,6 +5678,468 @@ describe('TUI user-interaction dialogs', () => { await dispose(result) }) + it('scrolls tall option lists with ↑/↓ overflow markers when the dialog height is capped', async () => { + const result = await setup({ + config: { + questionDialogWidth: 60, + questionDialogMaxHeight: 12, + maxQuestionOptions: 8, + }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'scroll', + question: 'Pick one', + options: [ + { label: 'Alpha', description: 'first choice with a description that will wrap to multiple lines when the dialog is narrow' }, + { label: 'Bravo', description: 'second choice' }, + { label: 'Charlie', description: 'third choice' }, + { label: 'Delta', description: 'fourth choice' }, + { label: 'Echo', description: 'fifth choice' }, + { label: 'Foxtrot', description: 'sixth choice' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('↓') + expect(result.terminal.output).toContain('more') + for (let step = 0; step < 5; step += 1) result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps controls visible when the selected option block exceeds the row budget', async () => { + const result = await setup({ + config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'oversize', + question: 'Pick one', + options: [ + { label: 'Huge', description: `start ${'middle '.repeat(40)}visible tail` }, + { label: 'Other' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Huge') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('↑↓ Tab ↵ Esc') + expect(result.terminal.output).not.toContain('visible tail') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('visible tail') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~') + result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('start middle') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('pages long question detail so every plan-review line remains reachable', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 10 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'long-detail', + question: 'Approve this plan?', + detail: `visible start ${'review step '.repeat(60)}visible tail`, + options: [{ label: 'Approve' }, { label: 'Reject' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(initialRender).toContain('plan?') + expect(initialRender).toContain('visible start') + expect(initialRender).not.toContain('visible tail') + expect(initialRender).toMatch(/PgUp\/PgDn \d+\/\d+/u) + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + const finalRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(finalRender).toContain('visible tail') + expect(finalRender).toContain('Approve') + result.terminal.send('\x1b[B') + await tick() + const movedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(movedRender).toContain('visible tail') + expect(movedRender).toContain('Reject') + result.terminal.send('\x1b[A') + result.terminal.send('\t') + await tick() + result.terminal.send('\x1b[6~') + await tick() + result.terminal.send('\x1b[5~') + result.terminal.resize(61, 20) + await tick() + const customPagedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(customPagedRender).not.toContain('visible tail') + expect(customPagedRender).toContain('Esc options') + result.terminal.send('\x1b') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~') + await tick() + const restoredRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(restoredRender).toContain('visible start') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('reclaims enough rows to keep selected content, paging, and option markers visible', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 8 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-row', + question: 'Pick one', + options: [ + { label: 'Selected first', description: `start ${'middle '.repeat(30)}visible tail` }, + { label: 'Hidden second' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Selected first') + expect(result.terminal.output).not.toContain('Hidden second') + expect(result.terminal.output).toContain('↓ 1 more') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('Esc interrupt') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('visible tail') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('preserves both option markers and controls at the minimum configured height', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'minimum-options', + question: 'Pick one', + multiSelect: true, + options: ['One', 'Two', 'Three', 'Four', 'Five'].map(label => ({ + label, + description: `${label} ${'wrapped detail '.repeat(20)}`, + })), + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑ 2 more') + expect(result.terminal.output).toContain('Three') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('↓ 2 more') + expect(result.terminal.output).toContain('Tab custom') + expect(result.terminal.output).toContain('Space toggle') + expect(result.terminal.output).toContain('Esc interrupt') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Error: Select at least one') + result.terminal.resize(61) + await tick() + const validationRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(validationRender).toContain('Tab custom') + expect(validationRender).toContain('Space toggle') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('preserves detail text and every action when one compact header row remains', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row', + question: 'Plan?', + detail: 'abcdvisible tail', + multiSelect: true, + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(initialRender).toContain('P↑↓ ↑↓ Tab S↵Esc') + result.terminal.send('\x1b[6~') + result.terminal.send('\x1b[6~') + await tick() + const detailRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(detailRender).toContain('visible tail') + result.terminal.send('\x03') + await rejected + + const single = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-single', + question: 'Plan?', + detail: 'abcdvisible tail', + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(61, 20) + await tick() + const singleRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(singleRender).toContain('P↑↓ ↑↓ Tab↵Esc') + result.terminal.send('\x03') + await singleRejected + + const compact = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-compact', + question: 'Pick?', + multiSelect: true, + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const compactRejected = expect(compact).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const compactRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(compactRender).toContain('↑↓ Tab Sp ↵Esc') + result.terminal.send('\x03') + await compactRejected + + const oneOption = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-one-option', + question: 'Pick?', + detail: 'Review every line.', + options: [{ label: 'Yes', description: 'wrapped detail '.repeat(8) }], + }], + }) + const oneOptionRejected = expect(oneOption).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(61, 20) + await tick() + const oneOptionRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(oneOptionRender).toContain('P↑↓ Tab↵Esc') + expect(oneOptionRender).not.toContain('P↑↓ ↑↓') + result.terminal.send('\x03') + await oneOptionRejected + await dispose(result) + }) + + it('expands the visible option window forward and backward around the selection', async () => { + const result = await setup({ + config: { + questionDialogWidth: 60, + questionDialogMaxHeight: 14, + maxQuestionOptions: 8, + }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'middle-scroll', + question: 'Pick one', + options: [ + { label: 'One', description: 'a' }, + { label: 'Two', description: 'b' }, + { label: 'Three', description: 'c' }, + { label: 'Four', description: 'd' }, + { label: 'Five', description: 'e' }, + { label: 'Six', description: 'f' }, + { label: 'Seven', description: 'g' }, + { label: 'Eight', description: 'h' }, + { label: 'Nine', description: 'i' }, + { label: 'Ten', description: 'j' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + for (let step = 0; step < 4; step += 1) result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑') + expect(result.terminal.output).toContain('↓') + expect(result.terminal.output).toContain('Five') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('wraps a long option label across multiple lines instead of truncating it', async () => { + const result = await setup({ config: { questionDialogWidth: 40 } }) + const longLabel = 'this is a very long option label that will not fit on one line in a narrow dialog' + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'long-label', + question: 'Pick one', + options: [{ label: longLabel }, { label: 'Short' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('narrow dialog') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('wraps fixed question chrome within the minimum dialog width', async () => { + const result = await setup({ config: { questionDialogWidth: 20 } }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'narrow', question: 'Answer?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).not.toContain('Question 1/1 (1 unanswered)') + expect(result.terminal.output).toContain('unanswered)') + expect(result.terminal.output).not.toContain('Enter submit • Esc cancel') + expect(result.terminal.output).toContain('Esc cancel') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps custom controls visible at the minimum dialog height', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'short-viewport', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('long question?') + expect(result.terminal.output).toContain('Esc cancel') + result.terminal.resize(60, 4) + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Enter an answer') + expect(result.terminal.output).toContain('long question?') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('compacts custom controls for a question that also has options', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'compact-custom-options', + question: 'Choose or type a deliberately long answer', + options: [{ label: 'Default' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Esc options') + result.terminal.send('\x1b') + await tick() + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('reports hidden question rows when the viewport leaves one row', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 2) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'one-row-dialog', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('lines hidden') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps question text when the viewport leaves two question rows', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 3) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'two-row-dialog', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('long question?') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('bounds option mode when the viewport leaves three question rows', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 4) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'three-row-options', + question: 'Pick one', + options: [{ label: 'First' }, { label: 'Second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('lines hidden') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps question rows within a sub-five-column viewport', async () => { + const terminal = new HeadlessTerminal(4, 12) + const result = await createTuiTestHarness(terminal, vi.fn(), { + config: { questionDialogWidth: 20 }, + }) + const beforeQuestion = terminal.frames + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'narrow-viewport', question: 'Pick?', options: [{ label: 'Yes' }] }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await terminal.waitForFrame(beforeQuestion) + await expect(terminal.snapshot()).resolves.toContain('terminal 4x12') + terminal.send('\x03') + await rejected + await disposeTuiTestHarness(result) + }) + it('asks batches in order and rejects cancelled or aborted work', async () => { const result = await setup() const preAborted = new AbortController()