Merge origin/master into fix/landlock-runner-failure-classification
This commit is contained in:
@@ -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/connection/README.md
|
||||
README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
|
||||
README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45
|
||||
README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9
|
||||
README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import { isLoopbackHostname } from './loopback-hostname.ts'
|
||||
|
||||
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
||||
interface ApiTrustRequest {
|
||||
@@ -25,14 +26,6 @@ function header(headers: IncomingHttpHeaders, name: string): string | undefined
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
|
||||
function parseAuthority(authority: string): URL | undefined {
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
import { isLoopbackHostname } from '../loopback-hostname.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
@@ -48,6 +49,8 @@ export const inject: string[] = []
|
||||
export interface ConnectionHandle {
|
||||
/** Shared api client (fixture or real, decided at boot from the page URL). */
|
||||
readonly api: IApiClient
|
||||
/** Whether the current page authority is loopback; non-browser contexts default to true. */
|
||||
readonly isLoopback: boolean
|
||||
/**
|
||||
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
|
||||
* One consumer owns the streams (the runtime object layer); a second call
|
||||
@@ -64,11 +67,13 @@ export interface ConnectionHandle {
|
||||
* @param ctx - client cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture')
|
||||
const pageLocation = typeof location === 'undefined' ? undefined : location
|
||||
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
|
||||
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
|
||||
let started = false
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
|
||||
18
packages/client/connection/src/loopback-hostname.ts
Normal file
18
packages/client/connection/src/loopback-hostname.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Browser-safe, zero-dependency loopback classification shared by the `/api`
|
||||
* Host fence and the package's `ctx.connection` state. The predicate stays
|
||||
* package-internal; client plugins consume the derived state through Cordis.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether a normalized URL hostname names the local loopback authority.
|
||||
* @param hostname - WHATWG URL hostname (IPv6 literals retain brackets).
|
||||
* @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8.
|
||||
*/
|
||||
export function isLoopbackHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost' || hostname === '[::1]') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts'
|
||||
import { FixtureApiClient } from '../src/client/fixture.ts'
|
||||
import { WebApiClient } from '../src/client/web-api-client.ts'
|
||||
|
||||
type Win = { location?: { search: string } }
|
||||
type Win = { location?: { hostname: string; search: string } }
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Win).location
|
||||
@@ -24,20 +24,28 @@ async function mount(): Promise<ConnectionHandle> {
|
||||
|
||||
describe('connection client apply', () => {
|
||||
it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => {
|
||||
;(globalThis as Win).location = { search: '' }
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
const handle = await mount()
|
||||
expect(handle.api).toBeInstanceOf(WebApiClient)
|
||||
expect(handle.isLoopback).toBe(true)
|
||||
})
|
||||
|
||||
it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => {
|
||||
;(globalThis as Win).location = { search: '?fixture' }
|
||||
;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' }
|
||||
expect((await mount()).api).toBeInstanceOf(FixtureApiClient)
|
||||
delete (globalThis as Win).location
|
||||
expect((await mount()).api).toBeInstanceOf(WebApiClient)
|
||||
const handle = await mount()
|
||||
expect(handle.api).toBeInstanceOf(WebApiClient)
|
||||
expect(handle.isLoopback).toBe(true)
|
||||
})
|
||||
|
||||
it('reports non-loopback page authority through the connection handle', async () => {
|
||||
;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' }
|
||||
expect((await mount()).isLoopback).toBe(false)
|
||||
})
|
||||
|
||||
it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => {
|
||||
;(globalThis as Win).location = { search: '?fixture' }
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
// config omitted: the `config ?? {}` default arm is part of the surface.
|
||||
const loop = handle.start({})
|
||||
@@ -46,7 +54,7 @@ describe('connection client apply', () => {
|
||||
})
|
||||
|
||||
it('WebApiClient carries requests over globalThis.fetch', async () => {
|
||||
;(globalThis as Win).location = { search: '' }
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const seen: string[] = []
|
||||
|
||||
18
packages/client/connection/tests/loopback-hostname.spec.ts
Normal file
18
packages/client/connection/tests/loopback-hostname.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Shared loopback-hostname semantics for the Host fence and browser UI. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isLoopbackHostname } from '../src/loopback-hostname.ts'
|
||||
|
||||
describe('isLoopbackHostname', () => {
|
||||
it('accepts localhost, IPv6 loopback, and the whole IPv4 127/8 block', () => {
|
||||
for (const hostname of ['localhost', '[::1]', '127.0.0.1', '127.8.9.10', '127.255.255.255']) {
|
||||
expect(isLoopbackHostname(hostname)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses malformed and non-loopback hostnames', () => {
|
||||
for (const hostname of ['remote.localhost', '::1', '128.0.0.1', '127.0.0', '127.0.0.256', '127.0.0.-1']) {
|
||||
expect(isLoopbackHostname(hostname)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,7 @@ async function mount(): Promise<Bench> {
|
||||
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => { bench.stopped += 1 } }
|
||||
|
||||
@@ -20,6 +20,7 @@ async function mount(): Promise<Bench> {
|
||||
const bench: Bench = { ctx, sinks: undefined }
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: true,
|
||||
start: (sinks) => {
|
||||
bench.sinks = sinks
|
||||
return { stop: () => {} }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <active id>`), 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 <preset>` 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 <preset>` 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.
|
||||
|
||||
|
||||
@@ -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: '<tool>', 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 <preset>`,而 `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 <preset>`,而 `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 · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy
|
||||
// control over one or more per-file hunks, each a bold path header followed by
|
||||
// the removed block (`-`, error color) and the added block (`+`, success
|
||||
// color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors
|
||||
// the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads
|
||||
// the same across front ends: the removed side is the old text in full, the
|
||||
// added side the new text in full, both split on the same terminator rule, and
|
||||
// the footer counts distinct paths on both ends. Output never soft-wraps — an
|
||||
// aligned source line keeps its indentation and scrolls horizontally instead of
|
||||
// folding. Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock.
|
||||
// color), with a dim `└ +A -R · N file(s)` footer. Unlike the TUI's exact
|
||||
// changed-row comparison, this block renders the old and new sides in full.
|
||||
// Both front ends share the line-terminator rule and distinct-path file count.
|
||||
// Output never soft-wraps — an aligned source line keeps its indentation and
|
||||
// scrolls horizontally instead of folding. Colors resolve through --dsw-*
|
||||
// tokens; geometry mirrors CodeBlock.
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -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-question/README.md
|
||||
README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345
|
||||
README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3
|
||||
README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c
|
||||
README.zh.md: 3c2b12b30dd2858c7b8f99193829c3274f3f8228
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot.
|
||||
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`.
|
||||
|
||||
A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。
|
||||
|
||||
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。
|
||||
|
||||
若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
|
||||
|
||||
|
||||
@@ -98,12 +98,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
|
||||
const choose = (label: string): void => {
|
||||
updateDraft((current) => {
|
||||
const selected = question.multiSelect === true
|
||||
? current.selected.includes(label)
|
||||
if (question.multiSelect === true) {
|
||||
const selected = current.selected.includes(label)
|
||||
? current.selected.filter(item => item !== label)
|
||||
: [...current.selected, label]
|
||||
: [label]
|
||||
return { selected, custom: '', skipped: false }
|
||||
return { ...current, selected, skipped: false }
|
||||
}
|
||||
return { selected: [label], custom: '', skipped: false }
|
||||
})
|
||||
if (question.multiSelect !== true && index < questions.length - 1) {
|
||||
setIndex(current => current + 1)
|
||||
@@ -129,7 +130,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
const custom = value.custom.trim()
|
||||
return {
|
||||
id: item.id,
|
||||
selected: custom === '' ? value.selected : [],
|
||||
selected: custom === '' || item.multiSelect === true ? value.selected : [],
|
||||
...(custom === '' ? {} : { custom }),
|
||||
}
|
||||
}),
|
||||
@@ -155,14 +156,17 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
|
||||
submitDrafts(drafts)
|
||||
}
|
||||
|
||||
// Shared by the inline custom input and the optionless textarea: typing a
|
||||
// custom draft clears any selection, and Enter continues the flow
|
||||
// (Shift+Enter stays a newline in the textarea; on the single-line input it
|
||||
// is inert either way).
|
||||
// Shared by the inline custom input and the optionless textarea: a
|
||||
// multi-select draft retains checked labels, while a single-select custom
|
||||
// answer replaces its selection. Enter continues the flow (Shift+Enter
|
||||
// stays a newline in the textarea; on the single-line input it is inert).
|
||||
const draftCustom = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
const value = event.target.value
|
||||
updateDraft(current => ({
|
||||
...current, selected: [], custom: value, skipped: false,
|
||||
...current,
|
||||
selected: question.multiSelect === true ? current.selected : [],
|
||||
custom: value,
|
||||
skipped: false,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -104,13 +104,19 @@ describe('QuestionComposer', () => {
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' }))
|
||||
fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' })
|
||||
const multiCustom = screen.getByPlaceholderText('输入你的答案')
|
||||
fireEvent.change(multiCustom, { target: { value: '沟通能力' } })
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' }))
|
||||
expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true')
|
||||
expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('true')
|
||||
expect((multiCustom as HTMLInputElement).value).toBe('沟通能力')
|
||||
fireEvent.keyDown(multiCustom, { key: 'Enter' })
|
||||
|
||||
// The domain face encoded the whole batch into one carrier envelope.
|
||||
expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [
|
||||
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
|
||||
{ id: 'detail', selected: [], custom: '要能独立排查线上问题' },
|
||||
{ id: 'signals', selected: ['系统设计', '代码质量'] },
|
||||
{ id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' },
|
||||
]))
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '正在提交…' }).disabled).toBe(true)
|
||||
})
|
||||
@@ -233,6 +239,11 @@ describe('QuestionComposer', () => {
|
||||
fireEvent.keyDown(custom, { key: 'Enter' })
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交' }))
|
||||
expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [
|
||||
{ id: 'profile', selected: ['工程落地型 (Recommended)'] },
|
||||
{ id: 'detail', selected: [], custom: 'x' },
|
||||
{ id: 'signals', selected: ['系统设计'] },
|
||||
]))
|
||||
expect(await screen.findByText('网络中断')).toBeTruthy()
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)
|
||||
|
||||
|
||||
@@ -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-settings-general/README.md
|
||||
README.md: 4dbd339c93171b330895ab66366e76fd06013704
|
||||
README.zh.md: 8ad6de99ce78d3bdb1e7b35e872e5bfe6790e758
|
||||
README.md: 0202d596f509feeba39a38254e8bab2fae27b649
|
||||
README.zh.md: adec73edda00d34e209772f0bcc54a994f593997
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
|
||||
|
||||
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
|
||||
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
|
||||
|
||||
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
|
||||
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface WelcomeNoticeInjected {
|
||||
export type WelcomeNoticeProps =
|
||||
PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected
|
||||
|
||||
/** Render the mandatory notice until its current version commits durably. */
|
||||
/** Render the mandatory notice until its current version is acknowledged. */
|
||||
export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
|
||||
const { complete, controller, useSnapshot, t } = props
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
|
||||
@@ -61,7 +61,7 @@ export function apply(ctx: ClientContext): void {
|
||||
// locale/change re-registration wiring.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const welcomeController = new WelcomeNoticeStore(connection.api)
|
||||
const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory')
|
||||
const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
|
||||
const welcomeInjected = (): WelcomeNoticeInjected => ({
|
||||
controller: welcomeController,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Durable welcome-notice state over the Host settings document. */
|
||||
/** Welcome-notice state, durable when the browser may use Host settings. */
|
||||
|
||||
import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -24,7 +24,7 @@ function acknowledgementOf(view: SettingsNamespaceView): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/** Coordinates welcome acknowledgement reads and the sole durable write. */
|
||||
/** Coordinates durable Host acknowledgement or a process-local remote fallback. */
|
||||
export class WelcomeNoticeStore {
|
||||
/** uSES-safe state source shared by the registered welcome step. */
|
||||
readonly store: SnapshotStore<WelcomeNoticeState> = createSnapshotStore({
|
||||
@@ -33,12 +33,22 @@ export class WelcomeNoticeStore {
|
||||
|
||||
private generation = 0
|
||||
|
||||
/** @param api - settings wire face used for durable reads and writes. */
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
|
||||
/**
|
||||
* @param api - settings wire face used for durable reads and writes.
|
||||
* @param persistence - remote browsers use memory because settings is loopback-only.
|
||||
*/
|
||||
constructor(
|
||||
private readonly api: Pick<IApiClient, 'settings'>,
|
||||
private readonly persistence: 'host' | 'memory' = 'host',
|
||||
) {}
|
||||
|
||||
/** Load the current acknowledgement from the Host settings document. */
|
||||
/** Load the acknowledgement from Host settings or initialize process-local state. */
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') {
|
||||
this.store.update((state) => { state.status = 'ready'; state.error = null })
|
||||
return
|
||||
}
|
||||
this.store.update((state) => { state.status = 'loading'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.describe({})
|
||||
@@ -64,12 +74,20 @@ export class WelcomeNoticeStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist this copy version. The path mutation is idempotent across tabs and
|
||||
* preserves every sibling setting; failure leaves the step unacknowledged.
|
||||
* @returns true only when the Host committed the acknowledgement.
|
||||
* Acknowledge this copy version. The Host path mutation is idempotent across
|
||||
* tabs and preserves sibling settings; remote fallback changes only this store.
|
||||
* @returns true when the selected persistence mode accepted the acknowledgement.
|
||||
*/
|
||||
async acknowledge(): Promise<boolean> {
|
||||
const generation = ++this.generation
|
||||
if (this.persistence === 'memory') {
|
||||
this.store.update((state) => {
|
||||
state.status = 'ready'
|
||||
state.acknowledged = true
|
||||
state.error = null
|
||||
})
|
||||
return true
|
||||
}
|
||||
this.store.update((state) => { state.status = 'saving'; state.error = null })
|
||||
try {
|
||||
const response = await this.api.settings.mutate({
|
||||
@@ -99,7 +117,9 @@ export class WelcomeNoticeStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh only after the welcome step has begun reading durable state.
|
||||
* Refresh only after welcome state has left idle. A memory-mode load retains
|
||||
* acknowledgement so reconnect and settings-change refreshes do not reopen a
|
||||
* process-local notice.
|
||||
* @param controller - welcome state owner whose current status decides whether to load.
|
||||
*/
|
||||
export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void {
|
||||
|
||||
@@ -25,7 +25,7 @@ const SEATS = [
|
||||
['settings.onboarding', WelcomeNotice],
|
||||
] as const
|
||||
|
||||
async function bench() {
|
||||
async function bench(isLoopback = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
@@ -47,7 +47,7 @@ async function bench() {
|
||||
},
|
||||
},
|
||||
}))
|
||||
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never)
|
||||
ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
|
||||
}
|
||||
|
||||
@@ -159,6 +159,19 @@ describe('ui-settings-general apply', () => {
|
||||
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
|
||||
})
|
||||
|
||||
it('keeps remote welcome acknowledgement process-local', async () => {
|
||||
const b = await bench(false)
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const entry = b.slots.entries('settings.onboarding')[0]!
|
||||
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
|
||||
|
||||
await controller.load()
|
||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true })
|
||||
expect(b.settingsDescribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
|
||||
const b = await bench()
|
||||
const redeclare = declare(b.slots)
|
||||
|
||||
@@ -30,6 +30,21 @@ function deferred<T>() {
|
||||
}
|
||||
|
||||
describe('WelcomeNoticeStore', () => {
|
||||
it('acknowledges in memory without calling loopback-only settings APIs', async () => {
|
||||
const describe = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory')
|
||||
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null })
|
||||
await expect(controller.acknowledge()).resolves.toBe(true)
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null })
|
||||
expect(describe).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('acknowledges only the exact current copy version', async () => {
|
||||
for (const [version, acknowledged] of [
|
||||
[undefined, false],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
.menu > .node {
|
||||
margin-left: -8px;
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
.row {
|
||||
|
||||
@@ -100,17 +100,12 @@
|
||||
}
|
||||
|
||||
.table tbody tr[data-request-only='true'] td {
|
||||
height: 1px;
|
||||
height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.table tbody tr[data-request-only='true']:has(+ tr[data-request-only='true']) td {
|
||||
/* Keep consecutive boundary markers from painting their halos over one another. */
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.table tbody tr[data-request-only='true']:last-child td {
|
||||
/* Retain the lower half of the 16px boundary marker at the table's end. */
|
||||
height: 9px;
|
||||
@@ -130,10 +125,12 @@
|
||||
}
|
||||
|
||||
.requestBoundaryControl {
|
||||
--request-boundary-base-left: 12px;
|
||||
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: -8px;
|
||||
left: 12px;
|
||||
left: calc(var(--request-boundary-base-left) + var(--request-boundary-offset, 0px));
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
@@ -198,6 +195,12 @@
|
||||
box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.requestBoundaryControl[data-request-status='error']::before,
|
||||
.requestBoundaryControl[data-request-status='error']:hover::before,
|
||||
.requestBoundaryControl[data-request-status='error']:focus-visible::before {
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.requestBoundaryControl:hover::after,
|
||||
.requestBoundaryControl:focus-visible::after {
|
||||
opacity: 1;
|
||||
@@ -402,7 +405,7 @@
|
||||
}
|
||||
|
||||
.requestBoundaryControl {
|
||||
left: 6px;
|
||||
--request-boundary-base-left: 6px;
|
||||
}
|
||||
|
||||
.kindSlot {
|
||||
@@ -1245,9 +1248,19 @@
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: 600 12px/18px var(--dsw-font-family);
|
||||
gap: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.thinkingChevron {
|
||||
flex: none;
|
||||
transition: transform 120ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.thinkingToggle[aria-expanded='true'] .thinkingChevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.thinkingToggle:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,10 @@ type TrajectorySplitStyle = CSSProperties & {
|
||||
'--trajectory-tool-request-width': string
|
||||
}
|
||||
|
||||
type RequestBoundaryStyle = CSSProperties & {
|
||||
'--request-boundary-offset': string
|
||||
}
|
||||
|
||||
function clampDetailsWidth(width: number, splitWidth: number): number {
|
||||
const maxWidth = Math.max(
|
||||
DETAILS_MIN_WIDTH,
|
||||
@@ -453,6 +457,22 @@ function indexRequestNumbers(
|
||||
return numbers
|
||||
}
|
||||
|
||||
function indexRequestBoundaryRuns(records: readonly TableRecord[]): ReadonlyMap<number, number> {
|
||||
const indexes = new Map<number, number>()
|
||||
let runLength = 0
|
||||
for (const record of records) {
|
||||
if (record.cell.requestOnly === true) {
|
||||
indexes.set(record.cell.index, runLength++)
|
||||
continue
|
||||
}
|
||||
if (runLength > 0 && record.groupStart && requestStep(record.group) !== undefined) {
|
||||
indexes.set(record.cell.index, runLength)
|
||||
}
|
||||
runLength = 0
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
function summarizeTurn(records: readonly TableRecord[]): string {
|
||||
const steps = new Set(
|
||||
records
|
||||
@@ -1212,7 +1232,8 @@ function MarkdownRecordContent({
|
||||
aria-expanded={thinkingExpanded}
|
||||
onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }}
|
||||
>
|
||||
{thinkingExpanded ? 'Thinking' : 'Thinking ...'}
|
||||
Thinking
|
||||
<IconChevronRightOutline14 className={css.thinkingChevron} size={12} />
|
||||
</button>
|
||||
{thinkingExpanded && (
|
||||
<MarkdownFragment
|
||||
@@ -1545,6 +1566,7 @@ export function TrajectoryTable({
|
||||
collapsedAssistants,
|
||||
)
|
||||
: filterRecords(allRecords, searchMatchIndexes)
|
||||
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
|
||||
const selected = allRecords.find(record => record.cell.index === selectedIndex)
|
||||
const selectedPrompt = selected?.cell.kind === 'system'
|
||||
? selected.cell.promptDetail
|
||||
@@ -1791,6 +1813,12 @@ export function TrajectoryTable({
|
||||
const requestInfo = request === undefined
|
||||
? undefined
|
||||
: sessionRequestNumbers?.find(candidate => candidate.number === request)
|
||||
const requestStatus = requestInfo?.status
|
||||
?? (record.cell.isError === true ? 'error' : undefined)
|
||||
const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0
|
||||
const requestBoundaryStyle: RequestBoundaryStyle = {
|
||||
'--request-boundary-offset': `${requestRunIndex * 8}px`,
|
||||
}
|
||||
const requestLabel = request === undefined
|
||||
? undefined
|
||||
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
|
||||
@@ -1882,6 +1910,9 @@ export function TrajectoryTable({
|
||||
aria-label={requestLabel}
|
||||
aria-pressed={requestSelected}
|
||||
data-label={requestLabel}
|
||||
data-request-run-index={requestRunIndex}
|
||||
data-request-status={requestStatus}
|
||||
style={requestBoundaryStyle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
selectRequest({
|
||||
@@ -1930,36 +1961,36 @@ export function TrajectoryTable({
|
||||
<span
|
||||
className={css.kindSlot}
|
||||
>
|
||||
<Tooltip
|
||||
label={KIND_LABEL[record.cell.kind]}
|
||||
side="right"
|
||||
<span
|
||||
className={`${css.kindTag} ${
|
||||
record.cell.kind === 'system'
|
||||
? css.systemNeutral
|
||||
: record.cell.kind === 'context'
|
||||
? css.contextGreen
|
||||
: record.cell.kind === 'compacted'
|
||||
? css.compacted
|
||||
: record.cell.kind === 'tool'
|
||||
? css.toolAmber
|
||||
: record.cell.kind === 'message'
|
||||
? css.assistantVioletBright
|
||||
: record.cell.kind === 'subtool'
|
||||
? css.subtoolAmber
|
||||
: css[record.cell.kind]
|
||||
}`}
|
||||
data-role-kind={record.cell.kind}
|
||||
>
|
||||
<span
|
||||
className={`${css.kindTag} ${
|
||||
record.cell.kind === 'system'
|
||||
? css.systemNeutral
|
||||
: record.cell.kind === 'context'
|
||||
? css.contextGreen
|
||||
: record.cell.kind === 'compacted'
|
||||
? css.compacted
|
||||
: record.cell.kind === 'tool'
|
||||
? css.toolAmber
|
||||
: record.cell.kind === 'message'
|
||||
? css.assistantVioletBright
|
||||
: record.cell.kind === 'subtool'
|
||||
? css.subtoolAmber
|
||||
: css[record.cell.kind]
|
||||
}`}
|
||||
data-role-kind={record.cell.kind}
|
||||
<Tooltip
|
||||
label={KIND_LABEL[record.cell.kind]}
|
||||
side="right"
|
||||
>
|
||||
<span className={css.kindTagIcon} aria-hidden="true">
|
||||
{KIND_ICON[record.cell.kind]}
|
||||
</span>
|
||||
<span className={css.kindTagLabel}>
|
||||
{KIND_LABEL[record.cell.kind]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className={css.kindTagLabel}>
|
||||
{KIND_LABEL[record.cell.kind]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -2497,7 +2528,7 @@ export function TrajectoryTable({
|
||||
)}
|
||||
{selectedAssistantRequestTarget !== undefined && (
|
||||
<OverviewSection
|
||||
label="Timing"
|
||||
label="Request Timing"
|
||||
onOpen={() => {
|
||||
selectRequest(selectedAssistantRequestTarget, 'timing')
|
||||
}}
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('TrajectoryTable', () => {
|
||||
it('shows assistant timing facts after keyboard selection', () => {
|
||||
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Timing' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request Timing' }))
|
||||
|
||||
expect(screen.getByText('500 ms')).toBeTruthy()
|
||||
expect(screen.getByText('1.00 s')).toBeTruthy()
|
||||
@@ -101,10 +101,13 @@ describe('TrajectoryTable', () => {
|
||||
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
|
||||
const toggle = screen.getByRole('button', { name: 'Thinking ...' })
|
||||
const toggle = screen.getByRole('button', { name: 'Thinking' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText(thinking)).toBeNull()
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
|
||||
})
|
||||
|
||||
@@ -222,19 +225,79 @@ describe('TrajectoryTable', () => {
|
||||
expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders responsive role icons with a custom tooltip', () => {
|
||||
it('marks failed requests and lays coincident request markers left to right', () => {
|
||||
const turns: readonly TrajectoryTurnModel[] = [
|
||||
{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'message',
|
||||
text: '',
|
||||
requestOnly: true,
|
||||
isError: true,
|
||||
timeSeconds: 0.1,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 2,
|
||||
kind: 'message',
|
||||
text: '',
|
||||
requestOnly: true,
|
||||
isError: true,
|
||||
timeSeconds: 0.1,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: 3,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 3,
|
||||
kind: 'message',
|
||||
text: 'Recovered response',
|
||||
timeSeconds: 0.1,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
]
|
||||
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
|
||||
const failed = screen.getByRole('button', { name: 'Request #1' })
|
||||
const retry = screen.getByRole('button', { name: 'Request #2' })
|
||||
const recovered = screen.getByRole('button', { name: 'Request #3' })
|
||||
expect(failed.getAttribute('data-request-status')).toBe('error')
|
||||
expect(failed.getAttribute('data-request-run-index')).toBe('0')
|
||||
expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px')
|
||||
expect(retry.getAttribute('data-request-run-index')).toBe('1')
|
||||
expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px')
|
||||
expect(recovered.getAttribute('data-request-run-index')).toBe('2')
|
||||
expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px')
|
||||
})
|
||||
|
||||
it('shows the custom role tooltip only from the responsive icon', () => {
|
||||
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
|
||||
const toolIcon = toolTag?.querySelector<HTMLElement>('[data-role-icon="wrench"]')
|
||||
|
||||
expect(toolTag).not.toBeNull()
|
||||
expect(toolTag?.getAttribute('title')).toBeNull()
|
||||
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
|
||||
expect(toolIcon).toBeTruthy()
|
||||
|
||||
fireEvent.mouseEnter(toolTag as HTMLElement)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.mouseEnter(toolIcon as HTMLElement)
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
expect(tooltip.textContent).toBe('TOOL')
|
||||
expect(tooltip.getAttribute('data-side')).toBe('right')
|
||||
fireEvent.mouseLeave(toolTag as HTMLElement)
|
||||
fireEvent.mouseLeave(toolIcon as HTMLElement)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 组合的应用内流程。
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<div className={css.hoverContent}>
|
||||
<div className={css.hoverTitle}>{displayTitle(node, t)}</div>
|
||||
@@ -182,8 +183,8 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number;
|
||||
before the first prompt. */}
|
||||
{!node.blank && <div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>}
|
||||
<div className={css.hoverStatus}>
|
||||
<StateDot state={node.running ? 'ongoing' : 'done'} />
|
||||
<span>{node.running ? t('status.running') : t('status.idle')}</span>
|
||||
<StateDot state={status.state} />
|
||||
<span>{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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))
|
||||
}}
|
||||
>
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
<span className={css.slot}>
|
||||
{status.state !== 'done' && (
|
||||
<>
|
||||
<StateDot state={status.state} />
|
||||
<span className={css.visuallyHidden}>{status.label}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.title}>{title}</span>
|
||||
{/* A blank New Session row is a provisional placeholder: nothing has
|
||||
happened in it yet, so a "now" timestamp and the row verbs
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -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(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
|
||||
@@ -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(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -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(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
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(<SessionNodeItem node={{ ...node, running: false }} currentId={undefined} now={0}
|
||||
onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
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(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
@@ -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(
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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/fs/tool-fs-search/README.md
|
||||
README.md: 93df279e958c569cd0b30618d3c3e80f773d452b
|
||||
README.zh.md: 5cec79577dc896c2a95f9539a9911adcdc23cfe1
|
||||
README.md: 78ffa069e56da5fc987913acf761eb5c6ae15b1a
|
||||
README.zh.md: 42b123d5c47d8f48bc21b6f9bed4905372ca8625
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **bash executor seam**, not by `ctx.fs` provider methods. At load, the package probes `command -v rg` through `ctx.bash`; if the executor cannot find ripgrep on its `PATH`, it logs a warning and registers no tools or prompt sections. Each call assembles a fixed ripgrep command (every model-controlled value through one package-private shell-quoting helper), runs it via `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` as an ordinary foreground tool call, parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `bash`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (`--no-config` prepended so a host `RIPGREP_CONFIG_PATH` cannot inject a `--pre` preprocessor into the unconfined spawn; model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
|
||||
```ts ignore-check
|
||||
// A deployment chooses how over-cap glob pages are selected.
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
|
||||
await ctx.plugin(LocalSubprocessService) // @deepseek-ai/dsh-subprocess-local
|
||||
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false })
|
||||
// Optional: a spill backend makes capped results fully recoverable.
|
||||
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
|
||||
```
|
||||
|
||||
Why bash-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The bash executor owns request defaulting/capping, subprocess execution, process-group termination, environment scrubbing, raw output capture, and backend substitution (local, sandboxed, remote); this package owns schemas, argument validation, shell quoting, parsing, retention, formatted-result spill, and timeout declaration. The tools never call `ctx.bash.start()` and never expose a bash task id — the call returns only after `rg` exits, times out, is aborted, or fails.
|
||||
Why spawn-backed: local workspace discovery is naturally a process-backed `rg` workflow, and putting search on `ctx.fs` would force every filesystem backend to grow a search API. The subprocess seam owns spawn execution, process-tree termination, environment scrubbing, and bounded output capture; this package owns schemas, argument validation, argv construction, parsing, retention, formatted-result spill, and timeout declaration. The tools never expose a background task — the call returns only after `rg` exits, is terminated by the cooperative timeout, is aborted, or fails.
|
||||
|
||||
## Deployment requirement: rg + co-located bash/filesystem
|
||||
## Deployment requirement: no host rg, co-located workdir/filesystem
|
||||
|
||||
The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin load; otherwise `glob` and `grep` are absent from the model-visible tool schema. Returned paths are displayed relative to the resolved bash workdir (the calling agent's session cwd when present, else the executor's configured default) and are follow-up-readable with `read` only when the bash workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
|
||||
The binary ships with the package on every supported platform (macOS/Linux/Windows, x64/arm64), so no host `rg` install is required and the tools register on every deployment. Returned paths are displayed relative to the resolved workdir (the calling agent's session cwd when present, else `process.cwd()`) and are follow-up-readable with `read` only when that workdir and the filesystem root are the same workspace. v1 documents that co-location requirement and performs no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -29,24 +29,26 @@ The mounted bash executor must be able to resolve `rg` from its `PATH` at plugin
|
||||
| `grepMaxMatches` | `250` | Max flat matches one `grep` call retains inline (matches Claude Code's `GrepTool` `head_limit`); later matches go to the formatted spill artifact. |
|
||||
| `grepMaxLineBytes` | `2000` | Byte cap per matched-line preview; the cut preserves UTF-8 boundaries and is marked `(line truncated)`. |
|
||||
| `rawOutputMaxBytes` | `20000000` | Max complete raw `rg` stdout a search will parse (matches Claude Code's ripgrep raw buffer); larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. |
|
||||
| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the bash backend's own timeout stays a second safety cap. |
|
||||
| `timeoutMs` | `30000` | Cooperative tool-call budget attached to both tool definitions, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`; the subprocess seam's terminate escalation is the hard kill. |
|
||||
| `graceMs` | `3000` | Terminate-escalation grace period the subprocess seam grants past `timeoutMs` before the search fails as `SEARCH_ABORTED`. |
|
||||
| `stderrMaxBytes` | `65536` | Diagnostic-tail budget for `rg` stderr, captured through the subprocess seam's collect disposition; a lossy read keeps only the tail (marked `[stderr truncated]`). |
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|---|---|---|
|
||||
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved bash workdir. Returns one FILE path per line; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. |
|
||||
| `glob` | `pattern`, `path?` | `rg --files --glob <pattern> --sort=modified --no-ignore --hidden` plus VCS metadata excludes (`.git`, `.svn`, `.hg`, `.bzr`, `.jj`, `.sl`). `path` is an optional **directory** search root; omitted means the resolved workdir. Returns one FILE path per line; `rg --files` never emits directory entries. The pattern keeps ripgrep semantics: without a `/` it matches the basename at any depth, so `*` matches the whole tree. Complete results stay modification-time ordered; over-cap presentation follows `sampleOverCapGlobResults`. |
|
||||
| `grep` | `pattern`, `path?`, `include?` | Line-oriented `rg --json` parse (no colon-splitting ambiguity). `pattern` is a ripgrep regex; `path` is an optional **file or directory** target; `include` is ONE positive glob filter — a comma-separated list or a negated (`!…`) value is rejected up front (brace alternation like `*.{ts,tsx}` is fine). Returns matches grouped by file as `Line N: <preview>`. |
|
||||
|
||||
Routine budgets stay out of the model-facing schema (no `head_limit`/`offset`/`case_insensitive`/output modes): a model that needs surrounding context reads the matched file with `read`; one that needs later results follows the returned spill locator's retrieval hint.
|
||||
|
||||
## Two budgets, two artifacts
|
||||
|
||||
Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam and parses only complete retained stdout; if the executor still returns `stdout.truncated`, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
|
||||
Raw `rg` stdout and stderr are internal transport details. Each search requests collect-mode budgets from the subprocess seam — complete stdout within `rawOutputMaxBytes` and a `stderrMaxBytes` diagnostic tail — with no spill files on either stream (the tool never reads a raw spill path). If the seam still reports a lossy stdout read, the search fails with `SEARCH_RAW_OUTPUT_OVERFLOW` and tells the model to narrow the query; a lossy stderr read only marks the diagnostic excerpt `[stderr truncated]`. A successful `glob` keeps the displayed search root and every acquired path in `{ root, paths }`; when sampling is enabled, `root` lets the Native renderer group an explicit relative or absolute search path by entries beneath that root rather than by its workdir prefix. `grep` keeps every acquired `{ path, lineNumber, line }` in `{ matches }`. Inline item and per-line preview caps apply only in the Native renderer. For a direct surface call with more logical results than the inline cap, post-policy best-effort saves the complete formatted preview through `ctx.spillStore.saveText()` and replaces only presentation with the configured page plus locator. Nested Code dispatches skip that spill because their full canonical value does not enter model context. Missing/failed spill keeps the inline page and reports that the complete result could not be saved—never an `isError`.
|
||||
|
||||
## Errors
|
||||
|
||||
Search-owned failures carry `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). An existing structured `HarnessError` rejected by the bash executor, including `SANDBOX_UNAVAILABLE`, propagates unchanged; only an untyped spawn, cwd, or shell-start rejection becomes `SEARCH_FAILED`, while an aborted signal remains `SEARCH_ABORTED`. ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
|
||||
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (a failed `rg` launch, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still lossy after the requested stdout capture budget), and `SEARCH_ABORTED` (cooperative tool timeout or caller cancellation). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -54,7 +56,7 @@ Search-owned failures carry `SearchError` (a `HarnessError` subclass), surfaced
|
||||
|
||||
#### What the model sees
|
||||
|
||||
After the load-time `rg` probe succeeds, every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
|
||||
Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section.
|
||||
|
||||
##### Glob guidance with `sampleOverCapGlobResults: true`
|
||||
|
||||
@@ -86,7 +88,7 @@ Prefix-stable while the plugin scope, sampling choice, and guidance text are unc
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; schemas are visible only after the load-time `rg` probe succeeds.
|
||||
The glob description states the configured over-cap ordering. The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) use `sampleOverCapGlobResults: true`; the tools are registered unconditionally.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -114,7 +116,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Search-owned failures render as `Error: <message>` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata; structured bash-executor failures retain their owning name and code.
|
||||
Failures are normalized as `Error: <message>` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -126,7 +128,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the bash workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
|
||||
- **Ripgrep is a deployment dependency** — a missing `rg` executable makes the package register no tools or guidance; an incompatible executable or one that disappears after registration fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located executor or another search consumer.
|
||||
- **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation.
|
||||
- **The packaged binary is fixed at dependency version** — `@vscode/ripgrep` covers the platforms it ships (macOS/Linux/Windows, x64/arm64); an unsupported platform or a corrupted install fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located workspace or another search consumer.
|
||||
- **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend.
|
||||
- **Sampling, when enabled, groups by first path segment beneath the search root only** — an over-cap `glob` page balances across those top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred.
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**面向模型的文件系统发现工具**(`glob`、`grep`)由 **bash 执行器 seam** 支持,而不是由 `ctx.fs` 提供方方法支持。加载时,本包(package)探测 `command -v rg`,探测通过 `ctx.bash` 进行;如果执行器无法在其 `PATH` 上找到 ripgrep,就记录警告,并且不注册工具或提示词段。每次调用都会组装固定的 ripgrep 命令(所有模型控制的值都经过同一个包私有 shell 引用辅助函数),通过 `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` 作为普通前台工具调用运行,解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `bash`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。
|
||||
**面向模型的文件系统发现工具**(`glob`、`grep`)由 **打包的 ripgrep 二进制**(`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 npm 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此无需引号),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。
|
||||
|
||||
```ts ignore-check
|
||||
// A deployment chooses how over-cap glob pages are selected.
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: process.cwd() }) // @deepseek-ai/dsh-bash-local
|
||||
await ctx.plugin(LocalSubprocessService) // @deepseek-ai/dsh-subprocess-local
|
||||
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: false })
|
||||
// Optional: a spill backend makes capped results fully recoverable.
|
||||
await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-spill-local
|
||||
```
|
||||
|
||||
采用 bash 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。bash 执行器负责请求默认值/上限、子进程执行、进程组终止、环境清理、原始输出捕获和后端替换(本地、沙箱化、远程);本包负责 schema、参数校验、shell 引用、解析、保留、格式化结果 spill 和超时声明。工具绝不调用 `ctx.bash.start()`,也不公开 bash task id;只有在 `rg` 退出、超时、中止或失败后,调用才会返回。
|
||||
采用 spawn 支持的原因:本地工作区发现天然是由进程支持的 `rg` 工作流;如果把搜索放到 `ctx.fs` 上,就会迫使每个文件系统后端扩展搜索 API。subprocess seam 负责 spawn 执行、进程树终止、环境清理和有界输出捕获;本包负责 schema、参数校验、argv 构造、解析、保留、格式化结果 spill 和超时声明。工具绝不暴露后台任务——只有在 `rg` 退出、被协作式超时终止、被中止或失败后,调用才会返回。
|
||||
|
||||
## 部署要求:rg 与共置的 bash/文件系统
|
||||
## 部署要求:无需宿主 rg,但工作目录与文件系统需共置
|
||||
|
||||
已挂载的 bash 执行器必须能在插件加载时解析 `rg`,其来源是执行器的 `PATH`;否则面向模型的工具 schema 中不会出现 `glob` 和 `grep`。返回路径会相对于解析后的 bash 工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用执行器配置的默认值);只有 bash 工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区契约或特定提供方的搜索后端。
|
||||
二进制随包交付,覆盖所有受支持平台(macOS/Linux/Windows,x64/arm64),因此无需宿主 `rg` 安装,工具在每个部署上都注册。返回路径会相对于解析后的工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用 `process.cwd()`);只有该工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。v1 只记录这项共置要求,不执行运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区契约或特定提供方的搜索后端。
|
||||
|
||||
## 配置
|
||||
|
||||
@@ -29,24 +29,26 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
|
||||
| `grepMaxMatches` | `250` | 一次 `grep` 调用内联保留的最大平铺匹配数(与 Claude Code 的 `GrepTool` `head_limit` 相同);后续匹配写入格式化 spill 产物。 |
|
||||
| `grepMaxLineBytes` | `2000` | 每条匹配行预览的字节上限;截断会保留 UTF-8 边界,并标记为 `(line truncated)`。 |
|
||||
| `rawOutputMaxBytes` | `20000000` | 搜索将解析的完整原始 `rg` stdout 上限(与 Claude Code 的 ripgrep 原始 buffer 相同);更大的原始输出以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败。 |
|
||||
| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;bash 后端自身的超时仍作为第二道安全上限。 |
|
||||
| `timeoutMs` | `30000` | 附加到两个工具定义上的协作式工具调用预算,由 `@deepseek-ai/dsh-timeout-policy` 通过 `exec.signal` 强制执行;subprocess seam 的终止升级提供硬终止。 |
|
||||
| `graceMs` | `3000` | subprocess seam 在 `timeoutMs` 之外授予的终止升级宽限期;超过后搜索以 `SEARCH_ABORTED` 失败。 |
|
||||
| `stderrMaxBytes` | `65536` | `rg` stderr 的诊断尾部预算,经 subprocess seam 的 collect 形态捕获;lossy 读取只保留尾部(标记 `[stderr truncated]`)。 |
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 参数 | 行为 |
|
||||
|---|---|---|
|
||||
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的 bash 工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 |
|
||||
| `glob` | `pattern`、`path?` | 运行 `rg --files --glob <pattern> --sort=modified --no-ignore --hidden`,并排除 VCS 元数据(`.git`、`.svn`、`.hg`、`.bzr`、`.jj`、`.sl`)。`path` 是可选的**目录**搜索根;省略时使用解析后的工作目录。每行返回一个**文件**路径;`rg --files` 从不输出目录条目。pattern 保留 ripgrep 语义:不含 `/` 时匹配任意深度的基名,因此 `*` 匹配整棵树。完整结果保持按修改时间排序;超过上限时的呈现方式遵循 `sampleOverCapGlobResults`。 |
|
||||
| `grep` | `pattern`、`path?`、`include?` | 按行解析 `rg --json`,避免按冒号拆分的歧义。`pattern` 是 ripgrep 正则表达式;`path` 是可选的**文件或目录**目标;`include` 是一个正向 glob 过滤器,前置拒绝逗号分隔列表或否定值(`!…`),但允许 `*.{ts,tsx}` 等花括号交替。返回按文件分组、形如 `Line N: <preview>` 的匹配。 |
|
||||
|
||||
常规预算不进入面向模型的 schema(没有 `head_limit`/`offset`/`case_insensitive`/输出模式):模型需要周边上下文时,用 `read` 读取匹配文件;需要后续结果时,遵循返回的 spill locator 检索提示。
|
||||
|
||||
## 两类预算、两类产物
|
||||
|
||||
原始 `rg` stdout 是内部传输细节。每次搜索从 bash seam 请求 `stdoutMaxBytes: rawOutputMaxBytes`,且只解析完整保留的 stdout;如果执行器仍返回 `stdout.truncated`,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
|
||||
原始 `rg` stdout 与 stderr 是内部传输细节。每次搜索从 subprocess seam 请求 collect 模式预算——`rawOutputMaxBytes` 内的完整 stdout 与 `stderrMaxBytes` 的诊断尾部——两条流都不产生 spill 文件(工具从不读取原始 spill 路径)。如果 seam 仍报告 lossy stdout 读取,搜索会以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败,并要求模型缩小查询;lossy stderr 读取只把诊断摘录标记为 `[stderr truncated]`。成功的 `glob` 在 `{ root, paths }` 中保留所显示的搜索根及所有已取得路径;启用采样时,借助 `root`,原生渲染器能以显式的相对或绝对搜索路径为根,按该根下的条目分组,而不是按其工作目录前缀分组。`grep` 保留所有已取得的 `{ path, lineNumber, line }`,并将其存入 `{ matches }`。内联条目和每行预览上限只应用于原生渲染器。直接接口调用的逻辑结果超过内联上限时,后置策略会尽力通过 `ctx.spillStore.saveText()` 保存完整格式化预览,并只把呈现替换为配置指定的页面与 locator。嵌套 Code 分派会跳过 spill,因为其完整规范值不会进入模型上下文。spill 缺失/失败时保留内联页面,并报告完整结果无法保存,绝不会成为 `isError`。
|
||||
|
||||
## 错误
|
||||
|
||||
搜索层自身的失败携带 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(注册后 `rg` 在运行时消失、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍被截断)和 `SEARCH_ABORTED`(工具超时、调用方取消或 bash 执行器自身超时)。bash 执行器拒绝并返回的既有结构化 `HarnessError`(包括 `SANDBOX_UNAVAILABLE`)会原样传播;只有无类型的 spawn、cwd 或 shell 启动拒绝会转换为 `SEARCH_FAILED`,中止信号仍为 `SEARCH_ABORTED`。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。
|
||||
搜索失败携带本包拥有的 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(`rg` 启动失败、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍 lossy)和 `SEARCH_ABORTED`(协作式工具超时或调用方取消)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -54,7 +56,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
加载时 `rg` 探测成功后,该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema,而不移除其提示词段。
|
||||
该插件注册作用域内的每个请求都包含下方独立注册的 glob 与 grep 指导。agent 作用域的工具限制可以隐藏任一 schema,而不移除其提示词段。
|
||||
|
||||
##### 启用 `sampleOverCapGlobResults: true` 时的 Glob 指导
|
||||
|
||||
@@ -76,57 +78,57 @@ Use the grep tool — not shell grep or rg — to search file contents. Use read
|
||||
|
||||
#### Token 影响
|
||||
|
||||
工具注册期间,每个请求支付固定指导成本;必填的采样选项决定采用哪个 glob 变体。
|
||||
工具注册期间每个请求有固定的指导成本;必填的采样选择决定采用哪一个 glob 变体。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要插件作用域、采样选项和指导文本不变,前缀就保持稳定。启用、dispose(资源释放)或更改该选项,可能从该提示词段开始使复用失效。
|
||||
插件作用域、采样选择与指导文本不变时前缀稳定。激活、销毁或改变选择可能使该提示词段的复用失效。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
glob 描述会说明配置所指定的超限结果排序方式。已生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`;只有加载时 `rg` 探测成功后,这些 schema 才可见。
|
||||
glob 描述声明了配置的超过上限排序方式。生成的 [`glob` 和 `grep` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) 使用 `sampleOverCapGlobResults: true`;工具无条件注册。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
工具可见的每个请求都支付固定 schema 成本。
|
||||
工具可见时每个请求有固定的 schema 成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要工具可见性和定义不变,前缀就保持稳定。注册生命周期或作用域限制可能从首个变化的 schema token 开始使复用失效。
|
||||
工具可见性与定义不变时前缀稳定。注册生命周期或作用域限制可能从第一个改变的 schema token 起使复用失效。
|
||||
|
||||
### 结果与 spill 通知
|
||||
### 结果与 spill 提示
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`glob` 每行返回一个路径;`grep` 在每个路径下对 `Line <line>: <preview>` 匹配分组。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果末尾会附加省略数量、spill locator 和后端检索提示,或说明完整结果无法保存。`sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面会在实际搜索根正下方的条目之间按轮转方式取路径,footer 会说明采样依据和触达的顶层条目数;若无法触达全部条目,footer 会要求模型缩小 `path`。设为 `false` 时,页面保留按修改时间排序的前部,并沿用通常用于达到上限结果的 footer。未超过上限的结果原样不动;扁平的采样结果也沿用普通 footer,因为其样本等同于按修改时间排序的前部。spill 产物始终保存按修改时间排序的完整列表。
|
||||
`glob` 每行返回一个路径;`grep` 在每个路径下分组展示 `Line <line>: <preview>` 匹配。空搜索返回 `No files found` 或 `No matches found`。达到上限的结果以省略计数结尾,并附 spill locator 与后端检索提示;否则说明完整结果无法保存。启用 `sampleOverCapGlobResults: true` 时,超过上限的 `glob` 页面按实际搜索根正下方的条目轮转取路径,页脚说明采样依据及其覆盖的顶层条目数;无法覆盖全部条目时,页脚提示模型收窄 `path`。`false` 时页面是按修改时间排序的前部,并保留普通的上限结果页脚。未超过上限的结果原样呈现;扁平采样的结果也保留普通页脚,因为其采样等于按修改时间排序的前部。spill 产物始终持有按修改时间排序的完整列表。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
内联路径和匹配受 `globMaxResults`、`grepMaxMatches` 与 `grepMaxLineBytes` 限制;调用和保留结果会留在历史中,直到上下文压缩(compaction)。
|
||||
内联路径与匹配受 `globMaxResults`、`grepMaxMatches` 与 `grepMaxLineBytes` 约束;调用与保留结果在压缩前留在历史中。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。
|
||||
|
||||
### 工具错误
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
搜索层自身的失败会渲染为 `Error: <message>`,并附带结构化的 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据;来自 bash 执行器的结构化失败则保留其原有名称和错误码。
|
||||
失败被规范化为 `Error: <message>`,并携带结构化 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据供调用方使用。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
只有失败调用会添加这些保留 token。
|
||||
只有失败的调用会增加这些保留 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
## 已知局限与延期工作
|
||||
|
||||
- **搜索和文件访问没有共享工作区证明**:只有 bash 工作目录和文件系统根目录表示同一工作区时,返回路径才能继续读取;本包不执行运行时跨服务校验。
|
||||
- **Ripgrep 是部署依赖**:缺失 `rg` 可执行文件时,本包不注册工具或指导;可执行文件不兼容或注册后消失时,调用以 `SEARCH_FAILED` 失败。远程或虚拟文件系统需要共置执行器或其他搜索消费方。
|
||||
- **schema 只公开一个有界页面**:offset 分页、大小写模式开关、其他输出模式和提供方支持的发现均不在本包内;达到上限的完整输出需要 spill 后端。
|
||||
- **启用采样时,只按搜索根下的路径首段分组**:超过上限的 `glob` 页面在这些顶层条目之间做均衡,因此集中在更深层的结果(一棵总体均匀的树里某个特别庞大的子目录)在该层级以下仍然分布不均;递归均衡已延期。
|
||||
- **搜索与文件访问没有共享工作区证明**——只有当工作目录与文件系统根目录指向同一工作区时,返回路径才保证可继续读取;本包不执行运行时跨服务校验。
|
||||
- **打包二进制固定在依赖版本上**——`@vscode/ripgrep` 覆盖其随附的平台(macOS/Linux/Windows,x64/arm64);不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。
|
||||
- **schema 只暴露一个有界页面**——偏移分页、大小写开关、替代输出模式与提供方支撑的发现仍不在本包范围内;达到上限的完整输出需要 spill 后端。
|
||||
- **启用采样时仅按搜索根正下方的第一段路径分组**——超过上限的 `glob` 页面在这些顶层条目之间平衡,因此集中在更深处的结果(一棵均匀树里某个繁忙目录)在该层级之下仍会呈现不均;递归平衡被延期。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-fs-search",
|
||||
"description": "Model-facing filesystem discovery tools (glob, grep) backed by the DeepSeek Harness bash seam (ctx.bash)",
|
||||
"description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -27,31 +27,27 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@vscode/ripgrep": "^1.18.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-retention": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-spill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-retention": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* The model-facing `glob` tool: discover files whose paths match a glob
|
||||
* pattern, sorted by modification time. Execution goes through the bash seam
|
||||
* (`ctx.bash`) with a fixed `rg --files` command — this module owns the
|
||||
* model-facing schema, argument validation, shell-safe command construction,
|
||||
* result parsing, inline sampling, and formatting; process concerns (defaulting,
|
||||
* scrubbing, kill, backend substitution) stay behind `ctx.bash`.
|
||||
* pattern, sorted by modification time. Execution spawns the packaged
|
||||
* ripgrep binary (`@vscode/ripgrep`) directly through the subprocess seam
|
||||
* with a plain argv vector — this module owns the model-facing schema,
|
||||
* argument validation, argv construction, result parsing, inline sampling,
|
||||
* and formatting; process concerns (spawn execution, tree termination,
|
||||
* environment scrubbing, output capture) stay behind `ctx.subprocess`.
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/glob
|
||||
*/
|
||||
|
||||
@@ -13,11 +14,9 @@ import { sep } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import { globSearchMeta, searchViewFromMeta } from './presentation.ts'
|
||||
import { singleQuote } from './shell-quote.ts'
|
||||
import { acceptedSurfaceValue } from './surface.ts'
|
||||
|
||||
/**
|
||||
@@ -48,6 +47,10 @@ export interface GlobToolCaps {
|
||||
maxMetaBytes: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
rawOutputMaxBytes: number
|
||||
/** Terminate-escalation grace period (ms) for the search process. */
|
||||
graceMs: number
|
||||
/** Cap on the retained stderr diagnostic tail. */
|
||||
stderrMaxBytes: number
|
||||
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
|
||||
timeoutMs: number
|
||||
}
|
||||
@@ -73,32 +76,35 @@ export function parseGlobArgs(args: { pattern: string; path?: string }): GlobInp
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fixed `rg --files` command for one `glob` call. Every
|
||||
* Build the fixed `rg --files` argv for one `glob` call. Every
|
||||
* model-controlled value ({@link GlobInput.pattern}, {@link GlobInput.path})
|
||||
* passes through {@link singleQuote}; the search root rides behind `--` so a
|
||||
* leading-dash path can never be parsed as a flag. `--sort=modified` orders by
|
||||
* modification time, `--no-ignore --hidden` searches ignored and hidden files,
|
||||
* and {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out.
|
||||
* is a plain argv element — no shell layer exists, so no quoting applies; the
|
||||
* search root rides behind `--` so a leading-dash path can never be parsed as
|
||||
* a flag. `--sort=modified` orders by modification time, `--no-ignore
|
||||
* --hidden` searches ignored and hidden files, and
|
||||
* {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out.
|
||||
*
|
||||
* @param input - the validated arguments.
|
||||
* @returns the complete, shell-safe command string.
|
||||
* @returns the complete ripgrep argument vector (excluding the binary itself).
|
||||
*/
|
||||
export function buildGlobCommand(input: GlobInput): string {
|
||||
export function buildGlobCommand(input: GlobInput): string[] {
|
||||
const parts = [
|
||||
'rg --files',
|
||||
`--glob=${singleQuote(input.pattern)}`,
|
||||
'--sort=modified --no-ignore --hidden',
|
||||
'--files',
|
||||
`--glob=${input.pattern}`,
|
||||
'--sort=modified',
|
||||
'--no-ignore',
|
||||
'--hidden',
|
||||
// Two negated globs per VCS name: the bare form prunes the directory
|
||||
// during traversal; the /** form still excludes the contents when the
|
||||
// search root is AT or INSIDE the directory (where the bare form,
|
||||
// matched against root-prefixed paths, never fires).
|
||||
...GLOB_VCS_EXCLUDES.flatMap(name => [
|
||||
`--glob=${singleQuote(`!**/${name}`)}`,
|
||||
`--glob=${singleQuote(`!**/${name}/**`)}`,
|
||||
`--glob=!**/${name}`,
|
||||
`--glob=!**/${name}/**`,
|
||||
]),
|
||||
]
|
||||
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
|
||||
return parts.join(' ')
|
||||
if (input.path !== undefined) parts.push('--', input.path)
|
||||
return parts
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,7 +291,7 @@ export function presentGlobResult(_args: { pattern: string; path?: string }, res
|
||||
* Register the `glob` tool and its system-prompt guidance.
|
||||
*
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and
|
||||
* execution uses its `bash` service.
|
||||
* execution uses its `subprocess` service.
|
||||
* @param caps - the deployment's resolved glob caps (plugin config after defaulting).
|
||||
*/
|
||||
export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
@@ -335,7 +341,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void {
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const input = parseGlobArgs(args)
|
||||
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes)
|
||||
const run = await runRipgrep(ctx, exec, 'glob', buildGlobCommand(input), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes)
|
||||
const root = input.path === undefined ? '.' : toWorkdirRelative(input.path, run.workdir)
|
||||
if (run.noMatches) return { root, paths: [] }
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* The model-facing `grep` tool: search file contents with a ripgrep regular
|
||||
* expression. Execution goes through the bash seam (`ctx.bash`) with a fixed
|
||||
* line-oriented `rg --json` command so file path, line number, and line text
|
||||
* parse without colon-splitting ambiguity — this module owns the model-facing
|
||||
* schema, argument validation, shell-safe command construction, `--json`
|
||||
* record parsing, per-line preview retention, match retention, grouping, and
|
||||
* formatting; process concerns stay behind `ctx.bash`.
|
||||
* expression. Execution spawns the packaged ripgrep binary
|
||||
* (`@vscode/ripgrep`) directly through the subprocess seam with a plain argv
|
||||
* vector using a fixed line-oriented `rg --json` command so file path, line
|
||||
* number, and line text parse without colon-splitting ambiguity — this module
|
||||
* owns the model-facing schema, argument validation, argv construction,
|
||||
* `--json` record parsing, per-line preview retention, match retention,
|
||||
* grouping, and formatting; process concerns stay behind `ctx.subprocess`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/grep
|
||||
*/
|
||||
@@ -15,12 +16,10 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { GrepMatch } from './search-core.ts'
|
||||
import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
import { grepSearchMeta, searchViewFromMeta } from './presentation.ts'
|
||||
import { singleQuote } from './shell-quote.ts'
|
||||
import { acceptedSurfaceValue } from './surface.ts'
|
||||
|
||||
/**
|
||||
@@ -46,6 +45,10 @@ export interface GrepToolCaps {
|
||||
maxMetaBytes: number
|
||||
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
||||
rawOutputMaxBytes: number
|
||||
/** Terminate-escalation grace period (ms) for the search process. */
|
||||
graceMs: number
|
||||
/** Cap on the retained stderr diagnostic tail. */
|
||||
stderrMaxBytes: number
|
||||
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
|
||||
timeoutMs: number
|
||||
}
|
||||
@@ -96,20 +99,21 @@ export function parseGrepArgs(args: { pattern: string; path?: string; include?:
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fixed line-oriented `rg --json` command for one `grep` call. Every
|
||||
* Build the fixed line-oriented `rg --json` argv for one `grep` call. Every
|
||||
* model-controlled value ({@link GrepInput.pattern}, {@link GrepInput.path},
|
||||
* {@link GrepInput.include}) passes through {@link singleQuote}; the pattern
|
||||
* and include ride in `--flag=value` form and the target behind `--`, so a
|
||||
* leading-dash value can never be parsed as a flag.
|
||||
* {@link GrepInput.include}) is a plain argv element — no shell layer exists,
|
||||
* so no quoting applies; the pattern and include ride in `--flag=value` form
|
||||
* and the target behind `--`, so a leading-dash value can never be parsed as
|
||||
* a flag.
|
||||
*
|
||||
* @param input - the validated arguments.
|
||||
* @returns the complete, shell-safe command string.
|
||||
* @returns the complete ripgrep argument vector (excluding the binary itself).
|
||||
*/
|
||||
export function buildGrepCommand(input: GrepInput): string {
|
||||
const parts = ['rg --json', `--regexp=${singleQuote(input.pattern)}`]
|
||||
if (input.include !== undefined) parts.push(`--glob=${singleQuote(input.include)}`)
|
||||
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
|
||||
return parts.join(' ')
|
||||
export function buildGrepCommand(input: GrepInput): string[] {
|
||||
const parts = ['--json', `--regexp=${input.pattern}`]
|
||||
if (input.include !== undefined) parts.push(`--glob=${input.include}`)
|
||||
if (input.path !== undefined) parts.push('--', input.path)
|
||||
return parts
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,7 +269,7 @@ export function presentGrepResult(
|
||||
* Register the `grep` tool and its system-prompt guidance.
|
||||
*
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and
|
||||
* execution uses its `bash` service.
|
||||
* execution uses its `subprocess` service.
|
||||
* @param caps - the deployment's resolved grep caps (plugin config after defaulting).
|
||||
*/
|
||||
export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
@@ -315,7 +319,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const input = parseGrepArgs(args)
|
||||
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes)
|
||||
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes)
|
||||
if (run.noMatches) return { matches: [] }
|
||||
|
||||
const all: GrepMatch[] = []
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
/**
|
||||
* The model-facing filesystem discovery tool suite (`glob`, `grep`) over the
|
||||
* bash executor seam (`ctx.bash`). This single plugin registers both tools
|
||||
* only when the mounted bash executor can find `rg` on its `PATH`.
|
||||
* packaged ripgrep binary (`@vscode/ripgrep`). This single plugin registers
|
||||
* both tools; the binary ships inside the npm dependency, so no system `rg`
|
||||
* install and no shell layer is involved.
|
||||
*
|
||||
* ## Bash-backed, not a `ctx.fs` provider method
|
||||
* ## Spawn-backed, not a `ctx.fs` provider method
|
||||
*
|
||||
* Local workspace discovery is a process-backed `rg` workflow, so these tools
|
||||
* execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)` with fixed
|
||||
* ripgrep command templates — never `ctx.bash.start()`, never a model-visible
|
||||
* background task. The tool layer owns schemas, argument validation, shell
|
||||
* quoting ({@link module:@deepseek-ai/dsh-tool-fs-search/shell-quote}), result
|
||||
* parsing, retention, formatted-result spill, and timeout declaration; the
|
||||
* bash executor owns request defaulting/capping, subprocess execution,
|
||||
* process-group termination, environment scrubbing, raw output capture, and
|
||||
* backend substitution. At load, the package probes `command -v rg` through the
|
||||
* same bash seam; if ripgrep is absent, `glob` / `grep` and their prompt
|
||||
* sections are not registered. The package injects `tools`, `systemPrompt`,
|
||||
* and `bash` — deliberately NOT `fs`, and `ctx.spillStore` is read
|
||||
* execute through `ctx.subprocess.spawn()` with fixed ripgrep argv templates —
|
||||
* never `ctx.bash`, never `ctx.bash.start()`, never a model-visible background
|
||||
* task. The tool layer owns schemas, argument validation, argv construction
|
||||
* ({@link module:@deepseek-ai/dsh-tool-fs-search/glob} /
|
||||
* {@link module:@deepseek-ai/dsh-tool-fs-search/grep}), result parsing,
|
||||
* retention, formatted-result spill, and timeout declaration; the subprocess
|
||||
* seam owns spawn execution, process-tree termination, environment scrubbing,
|
||||
* and raw output capture. The package injects `tools`, `systemPrompt`, and
|
||||
* `subprocess` — deliberately NOT `fs`, and `ctx.spillStore` is read
|
||||
* opportunistically with `ctx.get()` because formatted-result spill is optional.
|
||||
*
|
||||
* Returned paths are displayed relative to the resolved bash workdir and are
|
||||
* follow-up-readable only in co-located deployments where the bash workdir and
|
||||
* the filesystem `read` root are the same workspace — a documented v1
|
||||
* deployment requirement, not runtime-validated.
|
||||
* Returned paths are displayed relative to the resolved workdir and are
|
||||
* follow-up-readable only in co-located deployments where the workdir and the
|
||||
* filesystem `read` root are the same workspace — a documented v1 deployment
|
||||
* requirement, not runtime-validated.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search
|
||||
*/
|
||||
@@ -31,7 +30,7 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts'
|
||||
import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts'
|
||||
import { RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
|
||||
import { RAW_OUTPUT_MAX_BYTES, SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts'
|
||||
|
||||
export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult, sampleAcrossTopLevel } from './glob.ts'
|
||||
export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts'
|
||||
@@ -50,22 +49,24 @@ export {
|
||||
export type { GrepInput, GrepToolCaps } from './grep.ts'
|
||||
export {
|
||||
RAW_OUTPUT_MAX_BYTES,
|
||||
SEARCH_GRACE_MS,
|
||||
SEARCH_META_MAX_BYTES,
|
||||
SEARCH_STDERR_MAX_BYTES,
|
||||
SEARCH_TIMEOUT_MS,
|
||||
SearchError,
|
||||
previewLine,
|
||||
resolveRgPath,
|
||||
runRipgrep,
|
||||
toWorkdirRelative,
|
||||
trySaveFormattedResult,
|
||||
} from './search-core.ts'
|
||||
export type { GrepMatch, RipgrepRun, SearchErrorCode } from './search-core.ts'
|
||||
export { singleQuote } from './shell-quote.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-fs-search'
|
||||
|
||||
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
|
||||
export const inject = ['tools', 'systemPrompt', 'bash']
|
||||
export const inject = ['tools', 'systemPrompt', 'subprocess']
|
||||
|
||||
/** Plugin config; over-cap glob sampling is an explicit deployment choice and the remaining fields have defaults. */
|
||||
export interface Config {
|
||||
@@ -81,6 +82,10 @@ export interface Config {
|
||||
searchMetaMaxBytes?: number
|
||||
/** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
|
||||
rawOutputMaxBytes?: number
|
||||
/** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */
|
||||
graceMs?: number
|
||||
/** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */
|
||||
stderrMaxBytes?: number
|
||||
/** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
|
||||
timeoutMs?: number
|
||||
}
|
||||
@@ -92,15 +97,14 @@ export const Config: z<Config> = z.object({
|
||||
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
|
||||
searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES),
|
||||
rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES),
|
||||
graceMs: z.number().default(SEARCH_GRACE_MS),
|
||||
stderrMaxBytes: z.number().default(SEARCH_STDERR_MAX_BYTES),
|
||||
timeoutMs: z.number().default(SEARCH_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
/** The shape after schemastery applied the defaults. */
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/** POSIX-shell builtin probe for the ripgrep binary in the bash executor environment. */
|
||||
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
|
||||
|
||||
/** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
@@ -109,36 +113,14 @@ function assertPositiveInteger(name: string, value: number): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the mounted bash executor can find `rg`.
|
||||
*
|
||||
* Nonzero exit means "not available" and disables this optional tool suite.
|
||||
* Infrastructure failures stay loud: a deployment with a broken bash executor
|
||||
* should not silently lose tools in a way that looks like a deliberate skip.
|
||||
*
|
||||
* @param ctx - plugin context whose `bash` service is the executor the tools will use.
|
||||
* @returns true when `command -v rg` exits 0, false when it exits nonzero.
|
||||
*/
|
||||
async function ripgrepAvailable(ctx: Context): Promise<boolean> {
|
||||
const spec = ctx.bash.resolve({ command: RG_PROBE_COMMAND })
|
||||
let result
|
||||
try {
|
||||
result = await ctx.bash.run(spec)
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`tool-fs-search: ripgrep availability probe could not start: ${String(error)}`, { cause: error })
|
||||
}
|
||||
if (result.aborted || result.timedOut || result.signal !== null || result.exitCode === null) {
|
||||
throw new Error('tool-fs-search: ripgrep availability probe did not complete')
|
||||
}
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `glob`/`grep` filesystem discovery tool suite when `rg` exists.
|
||||
* Register the `glob`/`grep` filesystem discovery tool suite. The packaged
|
||||
* ripgrep binary is always available (an npm dependency), so registration is
|
||||
* unconditional.
|
||||
*
|
||||
* @param ctx - plugin context; registrations are effects scoped to this plugin.
|
||||
* @param config - resolved plugin configuration from schemastery.
|
||||
* @returns when ripgrep is unavailable, resolves without registering any tools.
|
||||
*/
|
||||
// oxlint-disable-next-line typescript/require-await -- async keeps a load-time config rejection a rejection, not a synchronous throw
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
// schemastery (Config) has already filled every defaulted field.
|
||||
const resolved = config as ResolvedConfig
|
||||
@@ -147,16 +129,16 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes)
|
||||
assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes)
|
||||
assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes)
|
||||
assertPositiveInteger('graceMs', resolved.graceMs)
|
||||
assertPositiveInteger('stderrMaxBytes', resolved.stderrMaxBytes)
|
||||
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
|
||||
if (!await ripgrepAvailable(ctx)) {
|
||||
ctx.logger.warn('tool-fs-search: ripgrep (rg) not found on the bash executor PATH; glob/grep tools not registered')
|
||||
return
|
||||
}
|
||||
applyGlobTool(ctx, {
|
||||
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
|
||||
maxResults: resolved.globMaxResults,
|
||||
maxMetaBytes: resolved.searchMetaMaxBytes,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
graceMs: resolved.graceMs,
|
||||
stderrMaxBytes: resolved.stderrMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
})
|
||||
applyGrepTool(ctx, {
|
||||
@@ -164,6 +146,8 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
maxLineBytes: resolved.grepMaxLineBytes,
|
||||
maxMetaBytes: resolved.searchMetaMaxBytes,
|
||||
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
||||
graceMs: resolved.graceMs,
|
||||
stderrMaxBytes: resolved.stderrMaxBytes,
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
})
|
||||
}
|
||||
|
||||
12
packages/fs/tool-fs-search/src/ripgrep.d.ts
vendored
Normal file
12
packages/fs/tool-fs-search/src/ripgrep.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Minimal type surface for the `@vscode/ripgrep` package: an ESM module that
|
||||
* resolves the platform ripgrep binary (`@vscode/ripgrep-<platform>-<arch>`
|
||||
* optional dependency) and exports its absolute path as the named export
|
||||
* `rgPath` (no bundled type declarations).
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/ripgrep-types
|
||||
*/
|
||||
|
||||
declare module '@vscode/ripgrep' {
|
||||
/** Absolute path to the packaged ripgrep executable for the current platform. */
|
||||
export const rgPath: string
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
/**
|
||||
* Shared execution plumbing for the `glob` / `grep` search tools: the
|
||||
* package-owned `SEARCH_*` error vocabulary, one bash-seam run helper that
|
||||
* turns a fixed `rg` command into complete raw stdout, the best-effort
|
||||
* formatted-result spill handoff, and workdir-relative path display.
|
||||
* package-owned `SEARCH_*` error vocabulary, one spawn helper that runs the
|
||||
* PACKAGED ripgrep binary (`@vscode/ripgrep`) with a plain argv vector and
|
||||
* returns complete raw stdout, the best-effort formatted-result spill handoff,
|
||||
* and workdir-relative path display.
|
||||
*
|
||||
* Both tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`
|
||||
* as ordinary foreground tool calls — never `ctx.bash.start()`, never a
|
||||
* model-visible background task. Raw `rg` stdout is an internal transport
|
||||
* detail: the tools request a per-run stdout capture budget from the bash seam,
|
||||
* parse only complete in-memory stdout within `rawOutputMaxBytes`, and never
|
||||
* read executor spill files. The model-facing recovery artifact is the
|
||||
* formatted result saved through `ctx.spillStore.saveText()`
|
||||
* Both tools execute as ordinary foreground spawns through `ctx.subprocess` —
|
||||
* never `ctx.bash`, never `ctx.bash.start()`, never a model-visible background
|
||||
* task. The ripgrep binary ships inside the npm package, so no system `rg`
|
||||
* install is required, and no shell layer exists between the argv vector and
|
||||
* ripgrep, so no shell quoting is involved. Raw `rg` stdout is an internal
|
||||
* transport detail: the tools request a per-run stdout capture budget from the
|
||||
* subprocess seam, parse only complete in-memory stdout within
|
||||
* `rawOutputMaxBytes`, and never read spill files. The model-facing recovery
|
||||
* artifact is the formatted result saved through `ctx.spillStore.saveText()`
|
||||
* ({@link trySaveFormattedResult}).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/search-core
|
||||
@@ -21,7 +24,7 @@ import type { Context } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import type { SubprocessHandle, SubprocessOutcome, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
@@ -38,6 +41,16 @@ export const RAW_OUTPUT_MAX_BYTES = 20_000_000
|
||||
*/
|
||||
export const SEARCH_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* Default cap in bytes on the retained stderr tail of one search run — a
|
||||
* diagnostic excerpt only (the tool never reads a stderr spill path, and the
|
||||
* collect disposition requests none).
|
||||
*/
|
||||
export const SEARCH_STDERR_MAX_BYTES = 64 * 1024
|
||||
|
||||
/** Default terminate grace period for a search process (ms). */
|
||||
export const SEARCH_GRACE_MS = 3_000
|
||||
|
||||
/**
|
||||
* Default cap in bytes on one search's serialized `presentationMeta` (the
|
||||
* `searchMetaMaxBytes` config). The inline match/path caps already bound the item
|
||||
@@ -52,14 +65,14 @@ export const SEARCH_META_MAX_BYTES = 65_536
|
||||
|
||||
/**
|
||||
* Stable, machine-routable codes for search failures. Package-owned (not
|
||||
* `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs`
|
||||
* `FsErrorCode`) because these tools are spawn-backed discovery, not `ctx.fs`
|
||||
* provider operations: `SEARCH_INVALID_PATTERN` — ripgrep rejected the regex or
|
||||
* glob; `SEARCH_FAILED` — the search could not run or its output could not be
|
||||
* parsed (missing `rg`, inaccessible target, signal kill, malformed `--json`);
|
||||
* `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded `rawOutputMaxBytes`
|
||||
* or stayed truncated after that requested stdout budget; `SEARCH_ABORTED` — the tool
|
||||
* timeout, caller cancellation, or the bash executor's own timeout cut the
|
||||
* search short.
|
||||
* parsed (a failed `rg` launch, inaccessible target, signal kill, malformed
|
||||
* `--json`); `SEARCH_RAW_OUTPUT_OVERFLOW` — raw `rg` output exceeded
|
||||
* `rawOutputMaxBytes` or stayed truncated after that requested stdout budget;
|
||||
* `SEARCH_ABORTED` — the cooperative tool timeout or caller cancellation cut
|
||||
* the search short.
|
||||
*/
|
||||
export type SearchErrorCode =
|
||||
| 'SEARCH_INVALID_PATTERN'
|
||||
@@ -84,7 +97,7 @@ export class SearchError extends HarnessError {
|
||||
|
||||
/** The completed acquisition of one `rg` run: complete stdout plus the resolved workdir. */
|
||||
export interface RipgrepRun {
|
||||
/** Complete raw stdout retained by the bash executor within the requested cap. */
|
||||
/** Complete raw stdout retained by the subprocess seam within the requested cap. */
|
||||
stdout: string
|
||||
/** True when ripgrep exited 1: a successful search with zero results. */
|
||||
noMatches: boolean
|
||||
@@ -94,128 +107,183 @@ export interface RipgrepRun {
|
||||
|
||||
/**
|
||||
* The retained stderr tail as a diagnostic excerpt, with a truncation note when
|
||||
* the executor dropped bytes (the tool never reads `stderr.spillPath`).
|
||||
* the subprocess seam dropped bytes.
|
||||
*/
|
||||
function stderrExcerpt(stderr: CollectedOutput): string {
|
||||
const text = stderr.text.trim()
|
||||
function stderrExcerpt(stderrText: string, truncated: boolean): string {
|
||||
const text = stderrText.trim()
|
||||
if (text.length === 0) return ''
|
||||
return stderr.truncated ? `${text} [stderr truncated]` : text
|
||||
return truncated ? `${text} [stderr truncated]` : text
|
||||
}
|
||||
|
||||
/** Classify a nonzero-exit `rg` run into the search error vocabulary (invalid pattern vs missing `rg` vs everything else). */
|
||||
function classifyRunFailure(toolName: string, result: BashRunResult): SearchError {
|
||||
const stderr = stderrExcerpt(result.stderr)
|
||||
/**
|
||||
* Classify a nonzero-exit `rg` run into the search error vocabulary. There is
|
||||
* no shell layer, so an exit 127 or shell "command not found" text cannot
|
||||
* occur — a launch failure rejects at spawn (see {@link runRipgrep}).
|
||||
*/
|
||||
function classifyRunFailure(toolName: string, exitCode: number, stderrText: string, stderrTruncated: boolean): SearchError {
|
||||
const stderr = stderrExcerpt(stderrText, stderrTruncated)
|
||||
if (/regex parse error|error parsing glob/i.test(stderr)) {
|
||||
return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, 'SEARCH_INVALID_PATTERN')
|
||||
}
|
||||
if (result.exitCode === 127 || /command not found/i.test(stderr)) {
|
||||
return new SearchError(`${toolName} requires ripgrep (rg) on the bash executor's PATH${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
|
||||
}
|
||||
return new SearchError(`${toolName} search failed (exit ${result.exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
|
||||
return new SearchError(`${toolName} search failed (exit ${exitCode})${stderr.length > 0 ? `: ${stderr}` : ''}`, 'SEARCH_FAILED')
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the COMPLETE raw stdout of a finished run, enforcing
|
||||
* `rawOutputMaxBytes` on the in-memory transport. A truncated result means the
|
||||
* bash backend could not retain complete stdout within the requested budget, so
|
||||
* the tool fails clearly instead of parsing a silently-partial stream.
|
||||
* subprocess seam could not retain complete stdout within the requested
|
||||
* budget, so the tool fails clearly instead of parsing a silently-partial
|
||||
* stream.
|
||||
*/
|
||||
function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxBytes: number): string {
|
||||
function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutputMaxBytes: number): string {
|
||||
const narrow = 'narrow pattern, path, or include and retry'
|
||||
if (!result.stdout.truncated) {
|
||||
const inlineBytes = Buffer.byteLength(result.stdout.text, 'utf8')
|
||||
if (!stdout.lossy) {
|
||||
const inlineBytes = Buffer.byteLength(stdout.text, 'utf8')
|
||||
if (inlineBytes > rawOutputMaxBytes) {
|
||||
throw new SearchError(
|
||||
`${toolName} produced ${inlineBytes} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
|
||||
'SEARCH_RAW_OUTPUT_OVERFLOW',
|
||||
)
|
||||
}
|
||||
return result.stdout.text
|
||||
return stdout.text
|
||||
}
|
||||
throw new SearchError(
|
||||
`${toolName} produced more raw output than the bash executor retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
|
||||
`${toolName} produced more raw output than the subprocess seam retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`,
|
||||
'SEARCH_RAW_OUTPUT_OVERFLOW',
|
||||
)
|
||||
}
|
||||
|
||||
let rgPathPromise: Promise<string> | undefined
|
||||
|
||||
/**
|
||||
* Run one fixed `rg` command through the bash seam and return its complete raw
|
||||
* stdout. The bash request workdir is the calling agent's session cwd
|
||||
* (`exec.agent.session.header.cwd`) when available — mirroring `dsh-tool-bash` /
|
||||
* `dsh-tool-fs` — else omitted so the implementation's `resolve()` applies its
|
||||
* configured default. `exec.signal` is forwarded so the cooperative tool
|
||||
* timeout (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation kill the
|
||||
* command; the bash backend's own timeout stays a second safety cap.
|
||||
* The packaged ripgrep binary path, resolved lazily once per process.
|
||||
*
|
||||
* `@vscode/ripgrep` resolves its platform package (`@vscode/ripgrep-<platform>
|
||||
* -<arch>`) at module evaluation, so a static import would turn a missing or
|
||||
* corrupt platform package (`pnpm install --omit=optional`, partial install)
|
||||
* into a failure of the whole Loader composition. Resolving at the call
|
||||
* boundary keeps that failure at the first search call as `SEARCH_FAILED` —
|
||||
* the package's documented no-load-time-probe contract.
|
||||
*
|
||||
* @returns the packaged binary's absolute path; the memoized promise rejects
|
||||
* when the platform package cannot be resolved.
|
||||
*/
|
||||
export function resolveRgPath(): Promise<string> {
|
||||
rgPathPromise ??= import('@vscode/ripgrep').then(module => module.rgPath)
|
||||
return rgPathPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the packaged ripgrep binary with a plain argv vector and return its
|
||||
* complete raw stdout. The working directory is the calling agent's session
|
||||
* cwd (`exec.agent.session.header.cwd`) when available, else
|
||||
* `process.cwd()`. `exec.signal` is forwarded so the cooperative tool timeout
|
||||
* (`@deepseek-ai/dsh-timeout-policy`) and caller cancellation terminate the
|
||||
* process tree.
|
||||
*
|
||||
* The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config`
|
||||
* is prepended: a host `RIPGREP_CONFIG_PATH` (or `rg.conf` next to the
|
||||
* binary) can otherwise inject `--pre` and make ripgrep execute an arbitrary
|
||||
* preprocessor for every matched file. The collect dispositions are the
|
||||
* seam's diagnostic-tail shape (no spill files): the tools never read a raw
|
||||
* spill path, and truncated stdout fails as `SEARCH_RAW_OUTPUT_OVERFLOW`.
|
||||
*
|
||||
* Exit semantics are tool-owned: exit 0 is success with results, exit 1 is
|
||||
* success with zero results (`noMatches`), anything else throws a
|
||||
* {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern →
|
||||
* `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` /
|
||||
* `SEARCH_RAW_OUTPUT_OVERFLOW`). A `run()` REJECTION — the seam's
|
||||
* infrastructure failures becomes `SEARCH_ABORTED` when the forwarded signal
|
||||
* aborted, propagates an existing structured {@link HarnessError} unchanged,
|
||||
* and wraps only untyped spawn/workdir/shell-start errors as `SEARCH_FAILED`.
|
||||
* `SEARCH_RAW_OUTPUT_OVERFLOW`). Both launch-time failure domains are
|
||||
* classified: a synchronous throw at spawn CREATION (a NUL in argv, an abort
|
||||
* racing the pre-check, a rejected `@vscode/ripgrep` resolution) and a
|
||||
* rejection of `handle.done` (the seam's infrastructure failures) both become
|
||||
* `SEARCH_FAILED` with the original as `cause` — an abort already observed by
|
||||
* creation time becomes `SEARCH_ABORTED` instead.
|
||||
*
|
||||
* @param ctx - the plugin context; execution uses its `bash` service.
|
||||
* @param ctx - the plugin context; execution uses its `subprocess` service.
|
||||
* @param exec - the tool-execution context; supplies the session cwd and the abort signal.
|
||||
* @param toolName - `glob` or `grep`, used in error messages.
|
||||
* @param command - the fully-quoted `rg` command string (every model value already through `singleQuote`).
|
||||
* @param argv - the ripgrep arguments (every model value an unquoted argv element; no shell layer exists).
|
||||
* @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse.
|
||||
* @param graceMs - the seam's terminate-escalation grace period.
|
||||
* @param stderrMaxBytes - cap on the retained stderr diagnostic tail.
|
||||
* @returns the complete stdout, the zero-result flag, and the resolved workdir.
|
||||
*/
|
||||
export async function runRipgrep(
|
||||
ctx: Context,
|
||||
exec: ToolExecution,
|
||||
toolName: string,
|
||||
command: string,
|
||||
argv: readonly string[],
|
||||
rawOutputMaxBytes: number,
|
||||
graceMs: number,
|
||||
stderrMaxBytes: number,
|
||||
): Promise<RipgrepRun> {
|
||||
const cwd = exec.agent?.session.header.cwd
|
||||
const spec = ctx.bash.resolve({
|
||||
command,
|
||||
stdoutMaxBytes: rawOutputMaxBytes,
|
||||
...cwd !== undefined ? { workdir: cwd } : {},
|
||||
signal: exec.signal,
|
||||
})
|
||||
let result: BashRunResult
|
||||
try {
|
||||
result = await ctx.bash.run(spec)
|
||||
} catch (error: unknown) {
|
||||
// Abort owns the outcome even when the executor rejects during teardown.
|
||||
if (spec.signal?.aborted === true) {
|
||||
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error })
|
||||
}
|
||||
// Infrastructure implementations may already provide a stable harness
|
||||
// error (notably SANDBOX_UNAVAILABLE); preserve that owning taxonomy.
|
||||
if (error instanceof HarnessError) throw error
|
||||
throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error })
|
||||
}
|
||||
if (result.aborted) {
|
||||
if (exec.signal.aborted) {
|
||||
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
|
||||
}
|
||||
if (result.timedOut) {
|
||||
throw new SearchError(`${toolName} timed out after ${result.timeoutMs}ms in the bash executor; narrow pattern, path, or include and retry`, 'SEARCH_ABORTED')
|
||||
const cwd = exec.agent?.session.header.cwd
|
||||
const workdir = cwd ?? process.cwd()
|
||||
let handle: SubprocessHandle
|
||||
try {
|
||||
handle = ctx.subprocess.spawn({
|
||||
argv: [await resolveRgPath(), '--no-config', ...argv],
|
||||
cwd: workdir,
|
||||
stdio: {
|
||||
stdin: 'ignore',
|
||||
stdout: { maxBytes: rawOutputMaxBytes },
|
||||
stderr: { maxBytes: stderrMaxBytes },
|
||||
},
|
||||
graceMs,
|
||||
signal: exec.signal,
|
||||
} satisfies SubprocessSpawnSpec)
|
||||
} catch (error: unknown) {
|
||||
// Node's spawn() throws synchronously for a NUL in argv, and the local
|
||||
// impl can throw synchronously when the signal aborts between the check
|
||||
// above and this call (or when the platform-package resolution rejects).
|
||||
// The static narrowing that proves this re-check "always false" cannot
|
||||
// see AbortSignal state changes.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
if (exec.signal.aborted) {
|
||||
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
|
||||
}
|
||||
throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error })
|
||||
}
|
||||
if (result.signal !== null || result.exitCode === null) {
|
||||
throw new SearchError(`${toolName} search command was killed by signal ${result.signal ?? '(unknown)'}`, 'SEARCH_FAILED')
|
||||
let outcome: SubprocessOutcome
|
||||
try {
|
||||
outcome = await handle.done
|
||||
} catch (error: unknown) {
|
||||
throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error })
|
||||
}
|
||||
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
||||
throw classifyRunFailure(toolName, result)
|
||||
const stdout = handle.collected.stdout?.readFrom(0)
|
||||
const stderr = handle.collected.stderr?.readFrom(0)
|
||||
if (stdout === undefined || stderr === undefined) {
|
||||
throw new SearchError(`${toolName} search command produced no collected output streams`, 'SEARCH_FAILED')
|
||||
}
|
||||
const stdout = completeStdout(toolName, result, rawOutputMaxBytes)
|
||||
return { stdout, noMatches: result.exitCode === 1, workdir: spec.workdir }
|
||||
// The signal can abort while the spawn is awaited; the static narrowing that
|
||||
// proves this re-check "always false" cannot see AbortSignal state changes.
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
||||
if (exec.signal.aborted) {
|
||||
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED')
|
||||
}
|
||||
if (outcome.signal !== null || outcome.exitCode === null) {
|
||||
throw new SearchError(`${toolName} search command was killed by signal ${outcome.signal ?? '(unknown)'}`, 'SEARCH_FAILED')
|
||||
}
|
||||
if (outcome.exitCode !== 0 && outcome.exitCode !== 1) {
|
||||
throw classifyRunFailure(toolName, outcome.exitCode, stderr.text, stderr.lossy)
|
||||
}
|
||||
const text = completeStdout(toolName, stdout, rawOutputMaxBytes)
|
||||
return { stdout: text, noMatches: outcome.exitCode === 1, workdir }
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an `rg` output path to its display form: absolute paths inside the
|
||||
* resolved bash workdir become workdir-relative; everything else (relative
|
||||
* output, paths outside the workdir) passes through unchanged. Display-only —
|
||||
* returned paths are follow-up-readable in co-located bash/filesystem
|
||||
* resolved workdir become workdir-relative; everything else (relative output,
|
||||
* paths outside the workdir) passes through unchanged. Display-only —
|
||||
* returned paths are follow-up-readable in co-located workdir/filesystem
|
||||
* deployments where both resolve the same workspace (the documented v1
|
||||
* deployment requirement).
|
||||
*
|
||||
* @param path - one path as ripgrep printed it.
|
||||
* @param workdir - the resolved bash workdir the command ran in.
|
||||
* @param workdir - the resolved workdir the command ran in.
|
||||
* @returns the workdir-relative display path when possible, else `path` unchanged.
|
||||
*/
|
||||
export function toWorkdirRelative(path: string, workdir: string): string {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* The one shell-quoting helper both search tools MUST route every
|
||||
* model-controlled value through before it enters an `rg` command string. The
|
||||
* bash seam (`ctx.bash`) accepts a command STRING, not an argv vector, so this
|
||||
* is the safety boundary that stops a `pattern`, `path`, or `include` from
|
||||
* breaking out of its argument and injecting shell syntax.
|
||||
*
|
||||
* Command builders in `glob.ts` / `grep.ts` must never hand-roll quoting or
|
||||
* concatenate an unquoted model value — they call {@link singleQuote}.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs-search/shell-quote
|
||||
*/
|
||||
|
||||
/**
|
||||
* POSIX single-quote a string for safe use as ONE shell word. Wraps the value
|
||||
* in single quotes and rewrites every embedded single quote as `'\''` (close
|
||||
* quote, an escaped literal quote, reopen quote). Inside single quotes the shell
|
||||
* treats every other byte literally — spaces, newlines, `$`, backticks, `;`,
|
||||
* `|`, `&`, glob metacharacters, and a leading `-` are all inert — so the result
|
||||
* is a single, injection-safe argument regardless of the input.
|
||||
*
|
||||
* @param value - the raw, possibly model-controlled string to quote.
|
||||
* @returns the value wrapped as one safe single-quoted shell word.
|
||||
*/
|
||||
export function singleQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* Integration tests: the REAL local bash executor (`dsh-bash-local`) plus a
|
||||
* REAL ripgrep binary, exercised through `ctx.tools.execute()`. These verify
|
||||
* the WORLD — actual files on disk are discovered and grepped, hostile
|
||||
* patterns stay inert in a real shell, and real `rg` stderr classifies into
|
||||
* the `SEARCH_*` vocabulary. The whole suite self-skips when `rg` is not on
|
||||
* PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor
|
||||
* suite (tools.spec.ts) carries the coverage gate.
|
||||
* Integration tests: the REAL local subprocess service plus the PACKAGED
|
||||
* ripgrep binary (`@vscode/ripgrep`), exercised through `ctx.tools.execute()`.
|
||||
* These verify the WORLD — actual files on disk are discovered and grepped,
|
||||
* hostile patterns stay inert (they are plain argv elements; there is no
|
||||
* shell layer to escape), and real `rg` stderr classifies into the
|
||||
* `SEARCH_*` vocabulary. The binary ships inside the npm dependency, so the
|
||||
* suite runs on every platform without a system `rg` install; the
|
||||
* fake-service suite (tools.spec.ts) carries the coverage gate.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -17,18 +18,11 @@ import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
const hasRg = spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
|
||||
@@ -47,7 +41,10 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () => {
|
||||
/** The fixture workspace as a session cwd, so relative paths resolve inside `dir`. */
|
||||
const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } })
|
||||
|
||||
describe('search tools over the real subprocess service + the packaged rg', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-search-int-'))
|
||||
await mkdir(join(dir, 'src'), { recursive: true })
|
||||
@@ -58,7 +55,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
|
||||
await writeFile(join(dir, 'notes.md'), 'alpha appears here too\n')
|
||||
await writeFile(join(dir, '.hidden.ts'), 'export const hidden = 3\n')
|
||||
await writeFile(join(dir, '.git', 'config.ts'), 'never listed\n')
|
||||
await writeFile(join(dir, 'spaced dir', "wei'rd \"name\".ts"), 'const inside = true\n')
|
||||
await writeFile(join(dir, 'spaced dir', "wei'rd name.ts"), 'const inside = true\n')
|
||||
// Deterministic --sort=modified order: alpha oldest, beta newest.
|
||||
await utimes(join(dir, 'src', 'alpha.ts'), new Date(2000, 0, 1), new Date(2000, 0, 1))
|
||||
await utimes(join(dir, 'src', 'beta.ts'), new Date(2020, 0, 1), new Date(2020, 0, 1))
|
||||
@@ -67,44 +64,42 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 })
|
||||
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('glob', () => {
|
||||
it('discovers files by pattern, sorted by modification time, hidden included, .git excluded', async () => {
|
||||
const result = await call('glob', { pattern: '**/*.ts' })
|
||||
const result = await call('glob', { pattern: '**/*.ts' }, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
const paths = text(result).split('\n')
|
||||
expect(paths.indexOf('src/alpha.ts')).toBeLessThan(paths.indexOf('src/beta.ts'))
|
||||
expect(paths.indexOf(join('src', 'alpha.ts'))).toBeLessThan(paths.indexOf(join('src', 'beta.ts')))
|
||||
expect(paths).toContain('.hidden.ts')
|
||||
expect(paths).toContain("spaced dir/wei'rd \"name\".ts")
|
||||
expect(paths).not.toContain('.git/config.ts')
|
||||
expect(paths).toContain(join('spaced dir', "wei'rd name.ts"))
|
||||
expect(paths).not.toContain(join('.git', 'config.ts'))
|
||||
expect(paths).not.toContain('notes.md')
|
||||
})
|
||||
|
||||
it('scopes to a directory search root (path arg)', async () => {
|
||||
const result = await call('glob', { pattern: '*.ts', path: 'src' })
|
||||
expect(text(result).split('\n').sort()).toEqual(['src/alpha.ts', 'src/beta.ts'])
|
||||
const result = await call('glob', { pattern: '*.ts', path: 'src' }, agent())
|
||||
expect(text(result).split('\n').sort()).toEqual([join('src', 'alpha.ts'), join('src', 'beta.ts')])
|
||||
})
|
||||
|
||||
it('reports zero discoveries as No files found', async () => {
|
||||
expect(text(await call('glob', { pattern: '*.nomatch' }))).toBe('No files found')
|
||||
expect(text(await call('glob', { pattern: '*.nomatch' }, agent()))).toBe('No files found')
|
||||
})
|
||||
|
||||
it('excludes VCS internals even when the search root IS the VCS directory', async () => {
|
||||
// The prune glob alone never matches root-prefixed paths when rg is
|
||||
// rooted at .git; the paired contents glob keeps the exclusion airtight.
|
||||
expect(text(await call('glob', { pattern: '*', path: '.git' }))).toBe('No files found')
|
||||
expect(text(await call('glob', { pattern: '*', path: '.git' }, agent()))).toBe('No files found')
|
||||
})
|
||||
|
||||
it('classifies an invalid glob as SEARCH_INVALID_PATTERN', async () => {
|
||||
const result = await call('glob', { pattern: '[' })
|
||||
const result = await call('glob', { pattern: '[' }, agent())
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } })
|
||||
})
|
||||
@@ -112,37 +107,42 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
|
||||
|
||||
describe('grep', () => {
|
||||
it('greps a directory tree with grouped, line-numbered output', async () => {
|
||||
const result = await call('grep', { pattern: 'alpha' })
|
||||
const result = await call('grep', { pattern: 'alpha' }, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
const output = text(result)
|
||||
expect(output).toContain('Found 3 matches')
|
||||
expect(output).toContain('src/alpha.ts\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha')
|
||||
expect(output).toContain(`${join('src', 'alpha.ts')}\nLine 1: export const alpha = 1\nLine 2: // TODO: refit alpha`)
|
||||
expect(output).toContain('notes.md\nLine 1: alpha appears here too')
|
||||
})
|
||||
|
||||
it('greps a single FILE target', async () => {
|
||||
const result = await call('grep', { pattern: 'alpha', path: 'notes.md' })
|
||||
const result = await call('grep', { pattern: 'alpha', path: 'notes.md' }, agent())
|
||||
expect(text(result)).toBe('Found 1 match\n\nnotes.md\nLine 1: alpha appears here too')
|
||||
})
|
||||
|
||||
it('greps a directory target with an include filter', async () => {
|
||||
const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' })
|
||||
const result = await call('grep', { pattern: 'alpha', path: '.', include: '*.ts' }, agent())
|
||||
const output = text(result)
|
||||
expect(output).toContain('alpha.ts')
|
||||
expect(output).not.toContain('notes.md')
|
||||
})
|
||||
|
||||
it('a hostile pattern stays inert (no command substitution, the world untouched)', async () => {
|
||||
it('a hostile pattern stays inert (a plain argv element, the world untouched)', async () => {
|
||||
// There is no shell layer between the argv vector and rg, so the pattern
|
||||
// is a literal regex — but the world-untouched guarantee is the shipped
|
||||
// contract, and a future shell-wrapping change must not reintroduce it.
|
||||
// The canary name carries no path so the regex stays valid on every
|
||||
// platform (a Windows path's backslashes would be regex escapes).
|
||||
const canary = join(dir, 'pwned')
|
||||
const result = await call('grep', { pattern: `$(touch ${canary})` })
|
||||
const result = await call('grep', { pattern: '$(touch pwned)' }, agent())
|
||||
expect(result.isError).toBe(false) // exit 1: found nothing, executed nothing
|
||||
expect(text(result)).toBe('No matches found')
|
||||
expect(spawnSync('test', ['-e', canary]).status).not.toBe(0)
|
||||
expect(existsSync(canary)).toBe(false)
|
||||
})
|
||||
|
||||
it('a leading-dash pattern is a pattern, not a flag', async () => {
|
||||
await writeFile(join(dir, 'dashes.txt'), 'value --flag value\n')
|
||||
const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' })
|
||||
const result = await call('grep', { pattern: '--flag', path: 'dashes.txt' }, agent())
|
||||
expect(text(result)).toBe('Found 1 match\n\ndashes.txt\nLine 1: value --flag value')
|
||||
})
|
||||
|
||||
@@ -160,7 +160,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
|
||||
})
|
||||
|
||||
describe('per-session cwd', () => {
|
||||
it('resolves the search in the SESSION workspace, not the executor config cwd', async () => {
|
||||
it('resolves the search in the SESSION workspace, not the process cwd', async () => {
|
||||
const sessionDir = await mkdtemp(join(tmpdir(), 'dsh-search-session-'))
|
||||
try {
|
||||
await writeFile(join(sessionDir, 'only-here.ts'), 'const sessionFile = true\n')
|
||||
@@ -175,7 +175,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pre-dispatch cancellation and bash-start failures', () => {
|
||||
describe('pre-dispatch cancellation and spawn failures', () => {
|
||||
it('a pre-aborted registry call is ABORTED_BEFORE_DISPATCH', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
@@ -197,59 +197,4 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
|
||||
expect(text(result)).toContain('could not start')
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves rg semantics through partial Landlock and propagates a real structured sandbox failure', async () => {
|
||||
await ctx.fiber.dispose()
|
||||
const launcher = join(dir, 'landlock-run')
|
||||
const failMarker = join(dir, 'fail-runner')
|
||||
await writeFile(launcher, `#!/bin/sh
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--ro|--rw) shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) printf '%s\\n' 'landlock-run: usage error: unexpected fake argument' >&2; exit 125 ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\\n' 'landlock-run: partial enforcement (older Landlock ABI)' >&2
|
||||
if [ -e ${ToolFsSearch.singleQuote(failMarker)} ]; then
|
||||
printf '%s\\n' 'landlock-run: landlock ruleset error: fixture failure' >&2
|
||||
exit 125
|
||||
fi
|
||||
exec "$@"
|
||||
`, { mode: 0o755 })
|
||||
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = {
|
||||
platform: 'linux',
|
||||
probeBwrap: () => false,
|
||||
probeLandlock: () => 'partial',
|
||||
landlockLauncher: launcher,
|
||||
}
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: dir })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: dir, timeoutMs: 20_000 })
|
||||
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
|
||||
|
||||
const grepNoMatch = await call('grep', { pattern: 'does-not-exist' })
|
||||
expect(grepNoMatch.isError).toBe(false)
|
||||
expect(text(grepNoMatch)).toBe('No matches found')
|
||||
|
||||
const globNoFiles = await call('glob', { pattern: '*.does-not-exist' })
|
||||
expect(globNoFiles.isError).toBe(false)
|
||||
expect(text(globNoFiles)).toBe('No files found')
|
||||
|
||||
const invalidRegex = await call('grep', { pattern: '(unclosed' })
|
||||
expect(invalidRegex.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } })
|
||||
|
||||
await writeFile(failMarker, '')
|
||||
const sandboxFailure = await call('grep', { pattern: 'alpha' })
|
||||
expect(sandboxFailure.error).toMatchObject({
|
||||
info: { name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE },
|
||||
})
|
||||
expect(text(sandboxFailure)).toContain('Runner failure: landlock-run: landlock ruleset error: fixture failure')
|
||||
expect(text(sandboxFailure)).not.toContain('could not start its search command')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
* a NAMESPACE plugin with `inject` — so a stray `export default apply` would
|
||||
* make the cordis Loader's `unwrapExports` (`exports.default ?? exports`)
|
||||
* collapse the module to the bare `apply` function, DROPPING `inject`. The
|
||||
* plugin would then read `ctx.bash` without having injected it and throw
|
||||
* plugin would then read `ctx.subprocess` without having injected it and throw
|
||||
* `cannot get property … without inject` the moment it loads (postmortem 0001).
|
||||
*
|
||||
* A hand-built `ctx.plugin({ apply, inject })` mount CANNOT catch that — it
|
||||
* bypasses `unwrapExports`. So this test unwraps the module through the REAL
|
||||
* `Loader.prototype.unwrapExports` and mounts the result over a bash executor,
|
||||
* exercising the exact path the Loader uses. Prove the guard bites: add
|
||||
* `export default apply` to `src/index.ts`, watch this go red, revert.
|
||||
* `Loader.prototype.unwrapExports` and mounts the result over the real local
|
||||
* subprocess service, exercising the exact path the Loader uses. Prove the
|
||||
* guard bites: add `export default apply` to `src/index.ts`, watch this go
|
||||
* red, revert.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -18,48 +19,9 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
const RG_PROBE_COMMAND = 'command -v rg >/dev/null 2>&1'
|
||||
|
||||
/**
|
||||
* Deterministic bash service for this Loader guard: the test wants to exercise
|
||||
* the real unwrap/inject path, not depend on whether the host image has rg.
|
||||
*/
|
||||
class ProbeSuccessBashExecutor extends BashExecutor {
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? '/work',
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
signal: request.signal,
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
override run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
if (spec.command !== RG_PROBE_COMMAND) {
|
||||
throw new Error(`unexpected command in load-path guard: ${spec.command}`)
|
||||
}
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
stdout: { text: '', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
})
|
||||
}
|
||||
|
||||
override start(): BashProcess {
|
||||
throw new Error('load-path guard must not start background processes')
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-tool-fs-search real-load-path guard', () => {
|
||||
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
|
||||
expect('default' in toolFsSearch).toBe(false)
|
||||
@@ -68,16 +30,16 @@ describe('dsh-tool-fs-search real-load-path guard', () => {
|
||||
const unwrapped = loader.unwrapExports(toolFsSearch) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(toolFsSearch)
|
||||
expect(unwrapped.name).toBe('tool-fs-search')
|
||||
expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'bash'])
|
||||
expect(unwrapped.inject).toEqual(['tools', 'systemPrompt', 'subprocess'])
|
||||
expect(typeof unwrapped.Config).toBe('function')
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
it('boots over ctx.bash through the unwrapped module without an inject error', async () => {
|
||||
it('boots over ctx.subprocess through the unwrapped module without an inject error', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ProbeSuccessBashExecutor)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolFsSearch) as Parameters<Context['plugin']>[0]
|
||||
|
||||
37
packages/fs/tool-fs-search/tests/rg-path.spec.ts
Normal file
37
packages/fs/tool-fs-search/tests/rg-path.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Failure-path tests for the lazy packaged-ripgrep resolution. The success
|
||||
* path (the real `@vscode/ripgrep` module) is exercised throughout
|
||||
* tools.spec.ts; here the module is mocked to throw at evaluation, proving a
|
||||
* missing or corrupt platform package (`--omit=optional`, partial install)
|
||||
* surfaces as a per-call `SEARCH_FAILED` — not a composition-load failure.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { resolveRgPath, runRipgrep } from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
// Any access to the mocked module's surface throws — the shape a missing
|
||||
// platform package produces at module evaluation.
|
||||
vi.mock('@vscode/ripgrep', () => new Proxy({}, {
|
||||
get() {
|
||||
throw new Error('platform package @vscode/ripgrep-win32-x64 is not installed')
|
||||
},
|
||||
}))
|
||||
|
||||
describe('lazy packaged-ripgrep resolution', () => {
|
||||
it('fails the first search call with SEARCH_FAILED instead of failing module load', async () => {
|
||||
// The resolution rejects before any spawn, so no subprocess service is needed.
|
||||
const controller = new AbortController()
|
||||
const exec = { signal: controller.signal, name: 'glob', callId: CallId('missing-platform-package') } as unknown as ToolExecution
|
||||
|
||||
await expect(runRipgrep(new Context(), exec, 'glob', ['--files'], 1_000_000, 3_000, 64 * 1024))
|
||||
.rejects.toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' })
|
||||
})
|
||||
|
||||
it('keeps failing every subsequent call (the resolution is memoized)', async () => {
|
||||
await expect(resolveRgPath()).rejects.toThrow(/platform package/)
|
||||
await expect(resolveRgPath()).rejects.toThrow(/platform package/)
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Unit tests for the shell-quoting safety boundary, plus a REAL round-trip:
|
||||
* every adversarial value, quoted, must survive `bash -c "printf '%s' <quoted>"`
|
||||
* byte-for-byte — proving the quoting is inert in an actual shell, not just
|
||||
* against a mental model of one.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { singleQuote } from '@deepseek-ai/dsh-tool-fs-search'
|
||||
|
||||
/** Adversarial values a model could pass as pattern / path / include. */
|
||||
const HOSTILE: readonly string[] = [
|
||||
'plain',
|
||||
'with spaces',
|
||||
"it's got 'quotes'",
|
||||
'"double quoted"',
|
||||
'$(rm -rf /tmp/nope)',
|
||||
'`touch /tmp/nope`',
|
||||
'$HOME and ${PATH}',
|
||||
'semi;colon && chain || pipe | bg &',
|
||||
'newline\nin the middle',
|
||||
'-leading-dash',
|
||||
'--leading-double-dash',
|
||||
'*?[a-z]{x,y}',
|
||||
'!bang',
|
||||
'\\backslash\\',
|
||||
'~tilde',
|
||||
'# not a comment',
|
||||
'>redirect <input 2>&1',
|
||||
]
|
||||
|
||||
describe('singleQuote', () => {
|
||||
it('wraps a plain value in single quotes', () => {
|
||||
expect(singleQuote('abc')).toBe("'abc'")
|
||||
})
|
||||
|
||||
it("rewrites embedded single quotes as '\\''", () => {
|
||||
expect(singleQuote("a'b")).toBe("'a'\\''b'")
|
||||
expect(singleQuote("''")).toBe("''\\'''\\'''")
|
||||
})
|
||||
|
||||
it.each(HOSTILE.map(value => [JSON.stringify(value), value] as const))(
|
||||
'round-trips %s through a real bash -c unchanged',
|
||||
(_label, value) => {
|
||||
const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(value)}`], { encoding: 'utf8' })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe(value)
|
||||
},
|
||||
)
|
||||
|
||||
it('a quoted command substitution does not execute (the world stays untouched)', () => {
|
||||
const canary = `/tmp/dsh-quote-canary-${process.pid}`
|
||||
const result = spawnSync('bash', ['-c', `printf '%s' ${singleQuote(`$(touch ${canary})`)}`], { encoding: 'utf8' })
|
||||
expect(result.stdout).toBe(`$(touch ${canary})`)
|
||||
// The canary file must NOT exist — the substitution stayed literal.
|
||||
expect(spawnSync('test', ['-e', canary]).status).not.toBe(0)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -244,8 +244,10 @@ describe('tool-str-replace-editor', () => {
|
||||
expect(listing).not.toContain('too-deep.txt')
|
||||
expect(listing).not.toContain('index.js')
|
||||
expect(listing).not.toContain('module.pyc')
|
||||
expect(listing).toContain('node_modules_old/kept.js')
|
||||
expect(listing).toContain('__pycache__backup/kept.py')
|
||||
// The listing carries absolute display paths; the POSIX-style substrings
|
||||
// only match on Linux, so assert with platform separators.
|
||||
expect(listing).toContain(join('node_modules_old', 'kept.js'))
|
||||
expect(listing).toContain(join('__pycache__backup', 'kept.py'))
|
||||
|
||||
const clipped = await setup({ maxOutputChars: 10 })
|
||||
await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100))
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: d4af267ef46f327bdabf31429b291ffd04308203
|
||||
README.zh.md: f148cfa0c023b54d62016c5211205d391b634544
|
||||
README.md: ed04a3431e4c9114b3115119c1e69d378cec77ae
|
||||
README.zh.md: e50a7f49cb8753f5b26dd27cb1d48a9cc18e9fd7
|
||||
|
||||
@@ -10,6 +10,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ
|
||||
|
||||
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
|
||||
|
||||
Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`.
|
||||
|
||||
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
|
||||
|
||||
首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。
|
||||
|
||||
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent(智能体),然后按追加来源的消息边界分页。`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
@@ -412,8 +412,10 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues
|
||||
if (new Set(answer.selected).size !== answer.selected.length) return false
|
||||
const custom = answer.custom?.trim()
|
||||
if (custom !== undefined && custom === '') return false
|
||||
if (custom !== undefined && answer.selected.length > 0) return false
|
||||
if (question.multiSelect !== true && answer.selected.length > 1) return false
|
||||
if (question.multiSelect !== true) {
|
||||
if (custom !== undefined && answer.selected.length > 0) return false
|
||||
if (answer.selected.length > 1) return false
|
||||
}
|
||||
const labels = new Set(question.options?.map(option => option.label) ?? [])
|
||||
return answer.selected.every(label => labels.has(label))
|
||||
})
|
||||
|
||||
116
packages/host/apiproxy/tests/api-proxy-question.spec.ts
Normal file
116
packages/host/apiproxy/tests/api-proxy-question.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
return {
|
||||
ctx,
|
||||
api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }),
|
||||
}
|
||||
}
|
||||
|
||||
function agent(id: string): Agent {
|
||||
return { id } as unknown as Agent
|
||||
}
|
||||
|
||||
function openMux(api: ApiProxy, abort: AbortController): {
|
||||
envelopes: RpcRequest<MuxFrame>[]
|
||||
waitForQuestion(): Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>
|
||||
} {
|
||||
const envelopes: RpcRequest<MuxFrame>[] = []
|
||||
let resolveQuestion!: (value: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>) => void
|
||||
const question = new Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>((resolve) => {
|
||||
resolveQuestion = resolve
|
||||
})
|
||||
void (async () => {
|
||||
for await (const envelope of api.events.mux({ rpcId: RpcId('question-mux'), payload: {} }, abort.signal)) {
|
||||
envelopes.push(envelope)
|
||||
if (envelope.payload.type === 'question/requested') {
|
||||
resolveQuestion(envelope as RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return { envelopes, waitForQuestion: () => question }
|
||||
}
|
||||
|
||||
function answer(
|
||||
envelope: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>,
|
||||
selected: string[],
|
||||
custom?: string,
|
||||
): Parameters<ApiProxy['respond']>[0] {
|
||||
return {
|
||||
type: 'client-response',
|
||||
rpcId: envelope.rpcId,
|
||||
result: {
|
||||
ok: true,
|
||||
value: {
|
||||
sessionId: envelope.payload.sessionId,
|
||||
answer: {
|
||||
answers: [{
|
||||
id: envelope.payload.questions[0]?.id,
|
||||
selected,
|
||||
...custom === undefined ? {} : { custom },
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('question response validation', () => {
|
||||
it('accepts selected options with custom text for multi-select questions', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const asked = ctx.userInteraction.ask({
|
||||
agent: agent('session-multi'),
|
||||
questions: [{
|
||||
id: 'targets',
|
||||
question: 'Choose targets and add another',
|
||||
multiSelect: true,
|
||||
options: [{ label: 'Code' }, { label: 'Docs' }],
|
||||
}],
|
||||
})
|
||||
const envelope = await mux.waitForQuestion()
|
||||
|
||||
expect(await api.respond(answer(envelope, ['Code', 'Docs'], 'Release notes')))
|
||||
.toEqual({ accepted: true })
|
||||
await expect(asked).resolves.toEqual({
|
||||
answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }],
|
||||
})
|
||||
expect(mux.envelopes.some(item => item.payload.type === 'question/resolved')).toBe(true)
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
it('keeps selected options and custom text mutually exclusive for single-select questions', async () => {
|
||||
const { ctx, api } = await harness()
|
||||
const abort = new AbortController()
|
||||
const mux = openMux(api, abort)
|
||||
const asked = ctx.userInteraction.ask({
|
||||
agent: agent('session-single'),
|
||||
questions: [{
|
||||
id: 'target',
|
||||
question: 'Choose one target',
|
||||
options: [{ label: 'Code' }, { label: 'Docs' }],
|
||||
}],
|
||||
})
|
||||
const envelope = await mux.waitForQuestion()
|
||||
|
||||
expect(await api.respond(answer(envelope, ['Code'], 'Release notes')))
|
||||
.toEqual({ accepted: false, reason: 'bad-response' })
|
||||
expect(await api.respond(answer(envelope, [], 'Release notes')))
|
||||
.toEqual({ accepted: true })
|
||||
await expect(asked).resolves.toEqual({
|
||||
answers: [{ id: 'target', selected: [], custom: 'Release notes' }],
|
||||
})
|
||||
abort.abort()
|
||||
})
|
||||
})
|
||||
@@ -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/tool-ask-user/README.md
|
||||
README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d
|
||||
README.zh.md: acaffec0764404a0e0e842ffc2b4efdee8869c4f
|
||||
README.md: 64da4d75d01a0df0ae51b1557ed1c796317b906f
|
||||
README.zh.md: 48a5b5d0d1aadff8a522d0b6100c6d0479b948a5
|
||||
|
||||
@@ -15,7 +15,7 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo
|
||||
- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label.
|
||||
- `multi_select` — whether that question may return more than one selected option.
|
||||
|
||||
The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`.
|
||||
The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` carries a free-form answer, supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`.
|
||||
|
||||
## Role
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
- `options`:可选选项,包含 `label` 和 `description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`。
|
||||
- `multi_select`:该问题是否可以返回多个选中的选项。
|
||||
|
||||
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`,并覆盖选中的选项。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。
|
||||
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;`custom` 携带自由填写的回答,对于多选题会补充 `selected`,对于单选题则会覆盖它。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。
|
||||
|
||||
## 职责
|
||||
|
||||
|
||||
@@ -140,7 +140,8 @@ describe('ask_user_question tool', () => {
|
||||
async ask() {
|
||||
return {
|
||||
answers: [
|
||||
{ id: 'targets', selected: ['tests', 'docs'] },
|
||||
{ id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
|
||||
{ id: 'labels-only', selected: ['tests'] },
|
||||
{ id: 'notes', selected: [], custom: 'ship today' },
|
||||
],
|
||||
}
|
||||
@@ -159,6 +160,12 @@ describe('ask_user_question tool', () => {
|
||||
options: [{ label: 'tests' }, { label: 'docs' }],
|
||||
multi_select: true,
|
||||
},
|
||||
{
|
||||
id: 'labels-only',
|
||||
question: 'Which labels should I keep?',
|
||||
options: [{ label: 'tests' }, { label: 'docs' }],
|
||||
multi_select: true,
|
||||
},
|
||||
{ id: 'notes', question: 'Any note?' },
|
||||
],
|
||||
},
|
||||
@@ -168,13 +175,14 @@ describe('ask_user_question tool', () => {
|
||||
if (result.isError) throw new Error('expected ask_user_question success')
|
||||
expect(result.value).toEqual({
|
||||
answers: [
|
||||
{ id: 'targets', selected: ['tests', 'docs'] },
|
||||
{ id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
|
||||
{ id: 'labels-only', selected: ['tests'] },
|
||||
{ id: 'notes', selected: [], custom: 'ship today' },
|
||||
],
|
||||
})
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text',
|
||||
text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
|
||||
text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
|
||||
}])
|
||||
})
|
||||
|
||||
|
||||
@@ -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: a577fb2f858f61eb765d4a1a9564f452d01d92c1
|
||||
README.zh.md: 61e1b9d526a00e3c8cbc2c9ed0cc483e2ab8dba2
|
||||
README.md: 78d56a6cacd040fdd32b73f779bab3fb4c77fcce
|
||||
README.zh.md: c403605bb13d252eec00a2b0ebafb5f953c884f8
|
||||
|
||||
@@ -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 `<session title> — <configured title>`. 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 `<session title> — <configured title>`. 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`.
|
||||
|
||||
@@ -32,11 +32,11 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
|
||||
|
||||
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
|
||||
|
||||
`/resume` opens a full-viewport keyboard selector instead of a centered dialog. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
|
||||
`/resume` opens a full-viewport keyboard selector instead of a centered dialog. The selector opens as soon as the command runs and takes input focus while the session scan is still pending, showing a loading placeholder until the rows arrive; Escape cancels an in-flight scan the same way it cancels the loaded list. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
|
||||
|
||||
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
|
||||
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Rows read no whole logs: when the optional projection cache is mounted, titles come from the live projection registry or the durable checkpoint row, with a cold read folding only the log tail since the checkpoint (written back so the next scan is zero-I/O, bounded by `resumeScanConcurrency`); a composition without the cache falls back to one bounded batch title read over the logs. Candidates are sorted by metadata activity — a live session's last in-memory event time, otherwise the persisted artifact's mtime, falling back to creation time — and searchable by title or session id, and by workspace label in the all-workspaces scope; each row reports that timestamp plus current/live/persisted state and the id. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, or a session with no recorded workspace to run in remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
|
||||
|
||||
Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
|
||||
Selection repeats those checks, fully reads and replay-validates the one chosen log, rejects it when its logged provider has no current adapter, and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
|
||||
|
||||
The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch.
|
||||
|
||||
@@ -50,11 +50,12 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY
|
||||
| `sessionId` | `main` | Exact shared agent/session identity driven by the terminal |
|
||||
| `showReasoning` | `true` | Render reasoning blocks |
|
||||
| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview |
|
||||
| `maxQuestionOptions` | `8` | Visible options in a question panel |
|
||||
| `maxDiffEditLength` | `1000` | Maximum added and removed lines explored for an exact diff before whole-side fallback |
|
||||
| `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 |
|
||||
@@ -73,6 +74,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY
|
||||
sessionId: main-session-123
|
||||
showReasoning: true
|
||||
maxToolOutputLines: 6
|
||||
maxDiffEditLength: 1000
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
@@ -84,7 +86,7 @@ Every general-purpose SGR code the TUI emits lives in one table, `paletteSpec` i
|
||||
|
||||
There is one role per visual meaning: `dim` is the single recessed tone, `accent` the single interaction emphasis, and `brand` the DeepSeek mark's standard-ANSI fallback, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair.
|
||||
|
||||
Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
|
||||
Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card with both sides available colors and counts exact added `+` and removed `-` lines, while unchanged context stays dim and uncounted. If exact comparison exceeds `maxDiffEditLength`, the card renders each old-side row as removed and each new-side row as added, marks the footer approximate, and caches that fallback for later redraws. When `oldText` is unavailable, including pending writes and replay fallbacks as well as creates, every non-empty new-side row is shown and counted as added; that count does not prove the rows were absent from an existing file. Empty new content produces no synthetic `+ ` row. A `[signal …]` marker remains colored because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -154,7 +156,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
#### What the model sees
|
||||
|
||||
When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels or `custom` text. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`.
|
||||
When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Pending custom text survives switching back to options and joins checked labels on a later options-mode submit. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -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`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `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`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `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`。
|
||||
|
||||
@@ -32,11 +32,11 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
|
||||
`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。
|
||||
|
||||
`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。
|
||||
`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。选择器在命令执行时立即打开并接管输入焦点,会话扫描仍在进行时显示加载占位符,直到行数据就绪;Escape 取消进行中的扫描,方式与取消已加载列表相同。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。
|
||||
|
||||
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
|
||||
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。行数据不读取任何完整日志:挂载可选的投影缓存时,标题来自实时投影注册表或持久化 checkpoint 行,冷读取只折叠 checkpoint 之后的日志尾部(并写回,使下次扫描零 I/O,受 `resumeScanConcurrency` 约束);未挂载缓存的组合回退到一次对日志的有界批量标题读取。候选项按元数据活动时间排序——实时会话取内存中最后一个事件的时间,否则取持久化产物的 mtime,再回退到创建时间——可按标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告该时间戳、current/live/persisted 状态和 id。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志,或没有可运行的已记录工作区的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
|
||||
|
||||
选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。
|
||||
选择时会重复这些检查,完整读取并回放验证所选中的那一份日志,在其日志所记提供方没有当前适配器时拒绝,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。
|
||||
|
||||
退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。
|
||||
|
||||
@@ -50,11 +50,12 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
| `sessionId` | `main` | 由终端驱动的精确共享 agent/会话身份 |
|
||||
| `showReasoning` | `true` | 渲染 reasoning 块 |
|
||||
| `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 |
|
||||
| `maxQuestionOptions` | `8` | 问题面板中可见的选项数 |
|
||||
| `maxDiffEditLength` | `1000` | 回退到整侧展示前,精确 diff 最多探索的新增与删除行总数 |
|
||||
| `maxQuestionOptions` | `8` | 一次最多可见的选项块数;行数边界可能进一步减少可见数量 |
|
||||
| `maxModelOptions` | `8` | 模型选择器中可见的模型数 |
|
||||
| `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 |
|
||||
| `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 |
|
||||
| `questionDialogMaxHeight` | `20` | 问题面板最大行数 |
|
||||
| `questionDialogMaxHeight` | `20` | 问题面板最大行数,会进一步受限以保留编辑器 |
|
||||
| `modelDialogWidth` | `76` | 模型选择器宽度(列数) |
|
||||
| `modelDialogMaxHeight` | `20` | 模型选择器最大行数 |
|
||||
| `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) |
|
||||
@@ -73,6 +74,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
sessionId: main-session-123
|
||||
showReasoning: true
|
||||
maxToolOutputLines: 6
|
||||
maxDiffEditLength: 1000
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
@@ -84,7 +86,7 @@ TUI 发出的所有通用 SGR 代码都集中在一个表中,即 `components/t
|
||||
|
||||
每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的交互强调色,`brand` 是 DeepSeek 标志的标准 ANSI 回退色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。
|
||||
|
||||
成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
|
||||
成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。当前后两侧文本均可用时,diff 卡片会为精确识别出的新增 `+` 行和删除 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。如果精确比较超出 `maxDiffEditLength`,卡片会把旧侧每一行渲染为删除行、把新侧每一行渲染为新增行,将页脚标记为近似结果,并缓存该回退结果供后续重绘使用。当 `oldText` 不可用时(包括待处理写入、回放回退以及文件创建),新侧的每个非空行都会显示并计作新增行;该计数不能证明这些行原先不存在于已有文件中。新内容为空时,不会补出虚构的 `+ ` 行。`[signal …]` 标记仍保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -154,7 +156,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签或 `custom` 文本。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。
|
||||
消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。切回选项后,待提交的自定义文本仍会保留,并在之后从选项模式提交时与已勾选的标签一同返回。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-reference": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
@@ -66,6 +68,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-tui": "0.80.7",
|
||||
"diff": "^9.0.0",
|
||||
"saxes": "6.0.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
@@ -81,6 +84,8 @@
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
/**
|
||||
* Session-resume sub-controller for the interactive chat channel: the
|
||||
* `/resume` selector, per-candidate summary reads that tolerate a corrupt
|
||||
* `/resume` selector, one metadata-plus-title scan that tolerates a corrupt
|
||||
* neighbor, the pre-handoff preflight, and the terminal handoff itself.
|
||||
* @module @deepseek-ai/dsh-tui/chat/resume
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { TUI } from '@earendil-works/pi-tui'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionLogSnapshot,
|
||||
SessionQueryService,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
@@ -66,50 +70,137 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
const workspaceLabel = (cwd: string | undefined): string =>
|
||||
runtime.formatCwd?.(cwd) ?? formatCwd(cwd)
|
||||
|
||||
/** Build one display candidate without letting a corrupt neighbor abort the selector. */
|
||||
const readResumeCandidate = async (
|
||||
/** Summarize one record from metadata and its batch-folded title. */
|
||||
const summarize = (
|
||||
record: SessionRecord,
|
||||
providers: ReadonlySet<string>,
|
||||
): Promise<ResumeCandidate> => {
|
||||
title: string | undefined,
|
||||
lastActivityAt: number | undefined,
|
||||
): ResumeCandidate => summarizeResumeCandidate(
|
||||
record,
|
||||
title,
|
||||
lastActivityAt,
|
||||
agent.session.id,
|
||||
agent.session.header.cwd,
|
||||
workspaceLabel,
|
||||
)
|
||||
|
||||
/** The disabled fallback row for a session whose title read failed. */
|
||||
const unreadableCandidate = (
|
||||
record: SessionRecord,
|
||||
lastActivityAt: number | undefined,
|
||||
error: unknown,
|
||||
): ResumeCandidate => ({
|
||||
record,
|
||||
title: 'Unreadable session',
|
||||
lastActivityAt: lastActivityAt ?? record.header.createdAt,
|
||||
currentWorkspace: record.header.cwd === agent.session.header.cwd,
|
||||
workspaceLabel: workspaceLabel(record.header.cwd),
|
||||
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
|
||||
})
|
||||
|
||||
/**
|
||||
* Metadata-only activity time: a live session's last in-memory event time,
|
||||
* otherwise the persisted artifact's mtime. Never reads a log, so browsing
|
||||
* cost stays independent of log size; any append (including bookkeeping)
|
||||
* moves it.
|
||||
*/
|
||||
const lastActivityAt = async (record: SessionRecord): Promise<number | undefined> => {
|
||||
const live = ctx.sessions.get(record.header.id)
|
||||
if (live !== undefined) return live.events.at(-1)?.time
|
||||
const location = ctx.get('sessionPersistence')?.locate(record.header)
|
||||
if (location === undefined) return undefined
|
||||
try {
|
||||
let snapshot: SessionLogSnapshot
|
||||
const live = ctx.sessions.get(record.header.id)
|
||||
if (live !== undefined) {
|
||||
snapshot = {
|
||||
session: structuredClone(live.header),
|
||||
events: live.events.map(event => structuredClone(event)),
|
||||
}
|
||||
} else {
|
||||
const readQuery = sessionQuery()
|
||||
/* v8 ignore start -- caller proves the optional service before mapping records */
|
||||
if (readQuery === undefined) throw new Error('session query is unavailable')
|
||||
/* v8 ignore stop */
|
||||
snapshot = await readQuery.readSession(record.header.id)
|
||||
}
|
||||
return summarizeResumeCandidate(
|
||||
record,
|
||||
snapshot,
|
||||
agent.session.id,
|
||||
agent.session.header.cwd,
|
||||
providers,
|
||||
workspaceLabel,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
record,
|
||||
title: 'Unreadable session',
|
||||
lastActivityAt: record.header.createdAt,
|
||||
lastTurn: 'log unavailable',
|
||||
currentWorkspace: record.header.cwd === agent.session.header.cwd,
|
||||
workspaceLabel: workspaceLabel(record.header.cwd),
|
||||
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
|
||||
}
|
||||
return (await stat(location.path)).mtimeMs
|
||||
} catch {
|
||||
// Only a just-deleted or never-materialized artifact fails stat; the row falls back to created-at.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One persisted row's title through the projection-cache ladder: the
|
||||
* zero-I/O checkpoint row when usable, otherwise a cold read that folds
|
||||
* only the log tail since the checkpoint and writes the refreshed row
|
||||
* back — so a store scanned once serves later scans without log reads.
|
||||
*/
|
||||
const projectedTitle = async (
|
||||
cache: SessionProjectionCache,
|
||||
record: SessionRecord,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | null | undefined> => {
|
||||
const live = ctx.sessions.get(record.header.id)
|
||||
if (live !== undefined) return ctx.get('sessionProjections')?.snapshot(live).values.title
|
||||
const cached = cache.cachedSnapshot(record.header)
|
||||
if (cached !== undefined && 'title' in cached.values) return cached.values.title
|
||||
return (await cache.coldSnapshot(record.header.id, signal)).values.title
|
||||
}
|
||||
|
||||
/** One per-record title resolution: a title (absent for untitled) or an isolated failure. */
|
||||
type TitleResolution = { title?: string; failure?: unknown }
|
||||
|
||||
/**
|
||||
* Resolve every row's title without reading whole logs when the projection
|
||||
* cache is mounted (live registry snapshot / checkpoint row / tail-only
|
||||
* cold read, bounded by `resumeScanConcurrency`); a composition without
|
||||
* the cache falls back to one bounded raw-log title batch.
|
||||
*/
|
||||
const resolveTitles = async (
|
||||
listQuery: SessionQueryService,
|
||||
records: readonly SessionRecord[],
|
||||
signal: AbortSignal,
|
||||
): Promise<TitleResolution[]> => {
|
||||
const cache = ctx.get('sessionProjectionCache')
|
||||
if (cache === undefined) {
|
||||
const results = await listQuery.readTitleSnapshots(records.map(record => record.header.id), signal)
|
||||
return records.map((record, index): TitleResolution => {
|
||||
const result = results[index]
|
||||
/* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */
|
||||
if (result === undefined || result.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`)
|
||||
if (result.status === 'rejected') return { failure: result.reason }
|
||||
const title = result.value.title?.title
|
||||
return title === undefined ? {} : { title }
|
||||
})
|
||||
}
|
||||
const resolutions = new Array<TitleResolution>(records.length)
|
||||
let cursor = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const index = cursor
|
||||
if (index >= records.length) return
|
||||
cursor += 1
|
||||
const record = records[index] as SessionRecord
|
||||
try {
|
||||
const value = await projectedTitle(cache, record, signal)
|
||||
resolutions[index] = typeof value === 'string' ? { title: value } : {}
|
||||
} catch (failure: unknown) {
|
||||
resolutions[index] = { failure }
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from(
|
||||
{ length: Math.min(resolved.resumeScanConcurrency, records.length) },
|
||||
() => worker(),
|
||||
))
|
||||
return resolutions
|
||||
}
|
||||
|
||||
/** The latest logged provider/model route, for the preflight availability check. */
|
||||
const resumeRoute = (events: readonly SessionEvent[]): { provider: string; model: string } | undefined => {
|
||||
const header = events.findLast(item => item.type === 'request/header')
|
||||
if (header?.type === 'request/header') {
|
||||
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
|
||||
}
|
||||
const assistant = events.findLast(item => item.type === 'assistant/message')
|
||||
return assistant?.type === 'assistant/message'
|
||||
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read every mutable precondition immediately before terminal handoff and
|
||||
* resolve the exact identity and workspace the host will re-exec into.
|
||||
* resolve the exact identity and workspace the host will re-exec into. This
|
||||
* is where the one chosen log is fully read, replay-validated, and checked
|
||||
* for a currently-available route — the listing never does any of that.
|
||||
*/
|
||||
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
|
||||
const query = sessionQuery()
|
||||
@@ -120,17 +211,24 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
|
||||
const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId)
|
||||
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
|
||||
const candidate = await readResumeCandidate(
|
||||
record,
|
||||
new Set(ctx.llm.listProviders().map(provider => provider.id)),
|
||||
)
|
||||
const candidate = summarize(record, undefined, undefined)
|
||||
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
|
||||
const cwd = candidate.record.header.cwd
|
||||
let events: readonly SessionEvent[]
|
||||
try {
|
||||
events = (await query.readSession(record.header.id)).events
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`session cannot be loaded: ${errorChain(error)}`)
|
||||
}
|
||||
const route = resumeRoute(events)
|
||||
if (route !== undefined && !ctx.llm.listProviders().some(provider => provider.id === route.provider)) {
|
||||
throw new Error(`session is complete, but route is currently unavailable (${route.provider}/${route.model})`)
|
||||
}
|
||||
const cwd = record.header.cwd
|
||||
/* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */
|
||||
if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`)
|
||||
const finalStatus = deps.agentStatus()
|
||||
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
|
||||
return { id: candidate.record.header.id, cwd }
|
||||
return { id: record.header.id, cwd }
|
||||
}
|
||||
|
||||
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
|
||||
@@ -194,40 +292,78 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
}
|
||||
const scan = ++resumeScan
|
||||
void resumeOverlay?.close()
|
||||
void listQuery.listSessions().then(async (records) => {
|
||||
if (deps.isDisposed() || scan !== resumeScan) return
|
||||
// Every workspace in the store is summarized; the picker owns the
|
||||
// current-workspace/all-workspaces scope split over the whole set.
|
||||
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
|
||||
const candidates = await Promise.all(records.map(record => readResumeCandidate(record, providers)))
|
||||
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|
||||
|| a.record.header.id.localeCompare(b.record.header.id))
|
||||
if (deps.isDisposed() || scan !== resumeScan) return
|
||||
const session = overlayManager.open({
|
||||
create: host => new ResumePicker(
|
||||
candidates,
|
||||
// The picker opens before the scan settles so the terminal stops feeding
|
||||
// the editor immediately; a queued activation (the closing predecessor
|
||||
// still holds the slot) receives an already-scanned set through
|
||||
// `scanned` instead of a loading placeholder.
|
||||
let picker: ResumePicker | undefined
|
||||
let scanned: ResumeCandidate[] | undefined
|
||||
const session = overlayManager.open({
|
||||
create: (host) => {
|
||||
picker = new ResumePicker(
|
||||
scanned,
|
||||
resolved.maxResumeOptions,
|
||||
workspaceLabel(agent.session.header.cwd),
|
||||
() => host.viewport.rows,
|
||||
palette,
|
||||
(candidate) => { void handoffResume(candidate, session) },
|
||||
() => { void session.close() },
|
||||
),
|
||||
options: {
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
anchor: 'top-left',
|
||||
margin: 0,
|
||||
},
|
||||
})
|
||||
resumeOverlay = session
|
||||
void session.closed.then(() => {
|
||||
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
|
||||
if (resumeOverlay === session) resumeOverlay = undefined
|
||||
)
|
||||
return picker
|
||||
},
|
||||
options: {
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
anchor: 'top-left',
|
||||
margin: 0,
|
||||
},
|
||||
})
|
||||
resumeOverlay = session
|
||||
// Closing the picker — Escape, supersession, disposal — aborts the scan:
|
||||
// the borrowed-log pass over a large store must not outlive its overlay.
|
||||
const scanAbort = new AbortController()
|
||||
void session.closed.then(() => {
|
||||
scanAbort.abort()
|
||||
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
|
||||
if (resumeOverlay === session) resumeOverlay = undefined
|
||||
})
|
||||
deps.requestRender()
|
||||
/** Whether this scan's overlay, session generation, or TUI is gone. */
|
||||
const scanStale = (): boolean =>
|
||||
deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted
|
||||
const scanCandidates = async (): Promise<void> => {
|
||||
// Every workspace in the store is listed; the picker owns the
|
||||
// current-workspace/all-workspaces scope split over the whole set.
|
||||
const records = await listQuery.listSessions(scanAbort.signal)
|
||||
if (scanStale()) return
|
||||
// Rows need only metadata, an mtime, and a title — resolved without
|
||||
// whole-log reads when the projection cache is mounted. A corrupt
|
||||
// neighbor degrades to one disabled row.
|
||||
const [titles, activity] = await Promise.all([
|
||||
resolveTitles(listQuery, records, scanAbort.signal),
|
||||
Promise.all(records.map(record => lastActivityAt(record))),
|
||||
])
|
||||
const candidates = records.map((record, index) => {
|
||||
const resolution = titles[index] as TitleResolution
|
||||
return 'failure' in resolution
|
||||
? unreadableCandidate(record, activity[index], resolution.failure)
|
||||
: summarize(record, resolution.title, activity[index])
|
||||
})
|
||||
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|
||||
|| a.record.header.id.localeCompare(b.record.header.id))
|
||||
if (scanStale()) return
|
||||
scanned = candidates
|
||||
picker?.setCandidates(candidates)
|
||||
deps.requestRender()
|
||||
}, (error: unknown) => {
|
||||
if (!deps.isDisposed() && scan === resumeScan) deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
|
||||
}
|
||||
// One catch covers listing, titles, and mtimes, so a scan failure
|
||||
// cannot strand the overlay on its loading placeholder; an aborted
|
||||
// scan's rejection stays silent because the user already dismissed the
|
||||
// picker.
|
||||
void scanCandidates().catch((error: unknown) => {
|
||||
if (scanStale()) return
|
||||
void session.close()
|
||||
deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -23,14 +23,8 @@ import {
|
||||
type AgentLlmTarget,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionLogSnapshot,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
|
||||
import { dialogSelectTheme, type Palette } from './theme.ts'
|
||||
@@ -506,95 +500,53 @@ export class DetailsDialog implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider/model route recovered from a resume candidate's log. */
|
||||
export interface ResumeRoute {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** A preflighted resume selector row summarizing one persisted session. */
|
||||
/** A resume selector row summarizing one session from metadata and its folded title. */
|
||||
export interface ResumeCandidate {
|
||||
record: SessionRecord
|
||||
title: string
|
||||
/** Last observed change: live last-event time or artifact mtime, falling back to creation. */
|
||||
lastActivityAt: number
|
||||
lastTurn: string
|
||||
/** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */
|
||||
currentWorkspace: boolean
|
||||
/** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */
|
||||
workspaceLabel: string
|
||||
route?: ResumeRoute
|
||||
goalPhase?: GoalPhase
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
|
||||
const event = snapshot.events.findLast(item => item.type === 'turn/end')
|
||||
if (event === undefined) return 'no completed turn'
|
||||
const reason = event.data.reason
|
||||
switch (reason.kind) {
|
||||
case 'completed': return `turn ${event.data.turn}: completed`
|
||||
case 'aborted': return `turn ${event.data.turn}: cancelled`
|
||||
case 'error': return `turn ${event.data.turn}: error`
|
||||
case 'disposed': return `turn ${event.data.turn}: disposed`
|
||||
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
|
||||
case 'interrupted': return `turn ${event.data.turn}: interrupted`
|
||||
default: return `turn ${event.data.turn}: unknown result`
|
||||
}
|
||||
}
|
||||
|
||||
function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
|
||||
const header = snapshot.events.findLast(item => item.type === 'request/header')
|
||||
if (header?.type === 'request/header') {
|
||||
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
|
||||
}
|
||||
const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
|
||||
return assistant?.type === 'assistant/message'
|
||||
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one resume selector row from a record and its log snapshot, deriving the
|
||||
* title, route, goal phase, workspace scope, and any reason the session cannot
|
||||
* be resumed here. A workspace other than the current one is a scope, not a
|
||||
* disabled reason: resuming it hands the process off into that directory.
|
||||
* Build one resume selector row from a record, its batch-folded title, and a
|
||||
* metadata-derived activity time, deriving the workspace scope and any reason
|
||||
* the session cannot be resumed here. A workspace other than the current one
|
||||
* is a scope, not a disabled reason: resuming it hands the process off into
|
||||
* that directory. Rows carry no per-log detail beyond the title — route and
|
||||
* replay validity are checked by the Enter-time preflight against the one
|
||||
* chosen log.
|
||||
* @param record - The session record.
|
||||
* @param snapshot - The session's log snapshot.
|
||||
* @param title - The session's batch-folded title, absent for an untitled log.
|
||||
* @param lastActivityAt - Metadata activity time; absent falls back to the header's creation time.
|
||||
* @param currentId - The current session id.
|
||||
* @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in.
|
||||
* @param availableProviders - Providers registered in this runtime.
|
||||
* @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label.
|
||||
* @returns The summarized resume candidate.
|
||||
*/
|
||||
export function summarizeResumeCandidate(
|
||||
record: SessionRecord,
|
||||
snapshot: SessionLogSnapshot,
|
||||
title: string | undefined,
|
||||
lastActivityAt: number | undefined,
|
||||
currentId: SessionId,
|
||||
cwd: string | undefined,
|
||||
availableProviders: ReadonlySet<string>,
|
||||
formatWorkspace: (cwd: string | undefined) => string,
|
||||
): ResumeCandidate {
|
||||
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
|
||||
const route = resumeRoute(snapshot)
|
||||
const foldedGoal = foldGoal(snapshot.events).goal
|
||||
let disabledReason: string | undefined
|
||||
if (record.header.id === currentId) disabledReason = 'current session'
|
||||
else if (record.live) disabledReason = 'session is already live in this runtime'
|
||||
else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace'
|
||||
else if (route !== undefined && !availableProviders.has(route.provider)) {
|
||||
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
|
||||
}
|
||||
return {
|
||||
record,
|
||||
title,
|
||||
// Excludes a prior pickup's boundary, or every browsed session floats up.
|
||||
lastActivityAt: lastActivityTime(snapshot.events) ?? snapshot.session.createdAt,
|
||||
lastTurn: resumeTurnLabel(snapshot),
|
||||
title: title ?? 'Untitled session',
|
||||
lastActivityAt: lastActivityAt ?? record.header.createdAt,
|
||||
currentWorkspace: record.header.cwd === cwd,
|
||||
workspaceLabel: formatWorkspace(record.header.cwd),
|
||||
...route === undefined ? {} : { route },
|
||||
/* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */
|
||||
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
|
||||
...disabledReason === undefined ? {} : { disabledReason },
|
||||
}
|
||||
}
|
||||
@@ -609,6 +561,10 @@ export type ResumeScope = 'workspace' | 'all'
|
||||
* current session's workspace, `all` lists every workspace and labels each row
|
||||
* with its own. Tab toggles between them; the search query and selection reset
|
||||
* on a scope change so the highlighted row always belongs to the visible list.
|
||||
*
|
||||
* The picker opens before the session scan settles: an `undefined` candidate
|
||||
* set renders a loading placeholder that keeps input away from the editor,
|
||||
* and `setCandidates` swaps the scanned rows in without replacing the overlay.
|
||||
*/
|
||||
export class ResumePicker implements Component, Focusable {
|
||||
private readonly search = new Input()
|
||||
@@ -616,27 +572,43 @@ export class ResumePicker implements Component, Focusable {
|
||||
private selectedIndex = 0
|
||||
private error = ''
|
||||
private scope: ResumeScope = 'workspace'
|
||||
private candidates: readonly ResumeCandidate[] | undefined
|
||||
focused = false
|
||||
|
||||
constructor(
|
||||
private readonly candidates: readonly ResumeCandidate[],
|
||||
candidates: readonly ResumeCandidate[] | undefined,
|
||||
private readonly maxVisible: number,
|
||||
private readonly workspaceLabel: string,
|
||||
private readonly viewportRows: () => number,
|
||||
private readonly palette: Palette,
|
||||
private readonly done: (candidate: ResumeCandidate) => void,
|
||||
private readonly cancel: () => void,
|
||||
) {}
|
||||
) {
|
||||
this.candidates = candidates
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.search.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the loading placeholder with the scanned candidate set.
|
||||
* @param candidates - the summarized rows the finished scan produced.
|
||||
*/
|
||||
setCandidates(candidates: readonly ResumeCandidate[]): void {
|
||||
this.candidates = candidates
|
||||
this.selectedIndex = 0
|
||||
// A still-loading error is false the moment rows exist.
|
||||
this.error = ''
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
/** Candidates in the active scope, before the search query narrows them. */
|
||||
private scoped(): ResumeCandidate[] {
|
||||
const candidates = this.candidates ?? []
|
||||
return this.scope === 'all'
|
||||
? [...this.candidates]
|
||||
: this.candidates.filter(candidate => candidate.currentWorkspace)
|
||||
? [...candidates]
|
||||
: candidates.filter(candidate => candidate.currentWorkspace)
|
||||
}
|
||||
|
||||
private filtered(): ResumeCandidate[] {
|
||||
@@ -653,7 +625,7 @@ export class ResumePicker implements Component, Focusable {
|
||||
private visibleCandidateCount(): number {
|
||||
// The all-workspaces scope adds a per-row workspace line, so a row costs
|
||||
// one more terminal row there than in the single-workspace scope.
|
||||
const rowHeight = this.scope === 'all' ? 5 : 4
|
||||
const rowHeight = this.scope === 'all' ? 4 : 3
|
||||
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight))
|
||||
return Math.min(this.maxVisible, candidateBudget)
|
||||
}
|
||||
@@ -718,7 +690,8 @@ export class ResumePicker implements Component, Focusable {
|
||||
this.error = ''
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
const selected = filtered[this.selectedIndex]
|
||||
if (selected === undefined) this.error = 'No session matches this search.'
|
||||
if (this.candidates === undefined) this.error = 'Sessions are still loading.'
|
||||
else if (selected === undefined) this.error = 'No session matches this search.'
|
||||
else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
|
||||
else this.done(selected)
|
||||
} else {
|
||||
@@ -738,12 +711,13 @@ export class ResumePicker implements Component, Focusable {
|
||||
* workspace it means, and the inactive scope with the count Tab would reveal.
|
||||
*/
|
||||
private renderScopeLine(): string {
|
||||
const inWorkspace = this.candidates.filter(candidate => candidate.currentWorkspace).length
|
||||
const candidates = this.candidates ?? []
|
||||
const inWorkspace = candidates.filter(candidate => candidate.currentWorkspace).length
|
||||
const active = this.scope === 'workspace'
|
||||
? `this workspace ${displayText(this.workspaceLabel)}`
|
||||
: `all workspaces (${this.candidates.length})`
|
||||
: `all workspaces (${candidates.length})`
|
||||
const other = this.scope === 'workspace'
|
||||
? `all workspaces (${this.candidates.length})`
|
||||
? `all workspaces (${candidates.length})`
|
||||
: `this workspace (${inWorkspace})`
|
||||
return `${this.palette.accent(active)}${this.palette.dim(` ⇥ ${other}`)}`
|
||||
}
|
||||
@@ -758,9 +732,12 @@ export class ResumePicker implements Component, Focusable {
|
||||
if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
|
||||
const selected = filtered[this.selectedIndex]
|
||||
const position = selected === undefined ? 0 : this.selectedIndex + 1
|
||||
const title = this.candidates === undefined
|
||||
? 'Resume session'
|
||||
: `Resume session (${position} of ${filtered.length})`
|
||||
const lines: string[] = [
|
||||
'',
|
||||
`${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`,
|
||||
`${indent}${this.palette.bold(this.palette.accent(title))}`,
|
||||
'',
|
||||
]
|
||||
|
||||
@@ -795,11 +772,7 @@ export class ResumePicker implements Component, Focusable {
|
||||
].filter((value): value is string => value !== undefined).join(' · ')
|
||||
const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}`
|
||||
push(active ? this.palette.bold(this.palette.accent(lead)) : lead)
|
||||
const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
|
||||
/* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */
|
||||
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
|
||||
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
|
||||
push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
|
||||
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${status} · ${displayText(candidate.record.header.id)}`))
|
||||
// Only the all-workspaces scope mixes directories, so the per-row
|
||||
// workspace is redundant in the scope that already names one.
|
||||
if (this.scope === 'all') {
|
||||
@@ -809,7 +782,8 @@ export class ResumePicker implements Component, Focusable {
|
||||
push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
|
||||
}
|
||||
}
|
||||
if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
|
||||
if (this.candidates === undefined) push(this.palette.dim('Loading sessions…'))
|
||||
else if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
|
||||
if (this.error !== '') {
|
||||
lines.push('')
|
||||
push(this.palette.error(displayText(this.error)))
|
||||
@@ -822,10 +796,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<number>()
|
||||
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 +820,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 +844,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,21 +859,27 @@ 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)
|
||||
else this.selected.add(this.selectedIndex)
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
|
||||
if (indices.length === 0) {
|
||||
const selected = this.question.multiSelect
|
||||
? this.selectedOptionLabels()
|
||||
: [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined)
|
||||
const custom = this.question.multiSelect ? this.input.getValue().trim() : ''
|
||||
if (selected.length === 0 && custom === '') {
|
||||
this.error = 'Select at least one option, or press Tab for a custom answer.'
|
||||
return
|
||||
}
|
||||
this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
|
||||
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()
|
||||
@@ -895,78 +892,362 @@ export class QuestionDialog implements Component, Focusable {
|
||||
this.error = 'Enter an answer before submitting.'
|
||||
return
|
||||
}
|
||||
this.done({ selected: [], custom })
|
||||
this.done({
|
||||
selected: this.question.multiSelect ? this.selectedOptionLabels() : [],
|
||||
custom,
|
||||
})
|
||||
}
|
||||
|
||||
private selectedOptionLabels(): string[] {
|
||||
return [...this.selected]
|
||||
.sort((a, b) => a - b)
|
||||
.map(index => this.options[index]?.label)
|
||||
.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)
|
||||
push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
|
||||
} 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<AskUserQuestionItem['options']>[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<AskUserQuestionItem['options']>[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type Component,
|
||||
type MarkdownTheme,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import { diffLines as compareLines } from 'diff'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
@@ -52,12 +53,17 @@ function pretty(value: unknown): string {
|
||||
return displayText(serialized ?? String(value))
|
||||
}
|
||||
|
||||
interface RenderedDiff {
|
||||
lines: string[]
|
||||
added: number
|
||||
removed: number
|
||||
approximate: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A side's content lines under the terminator rule the Web DiffBlock also
|
||||
* applies: empty text is zero lines (a full deletion's `newText`, a create's
|
||||
* absent `oldText`), and a single trailing newline terminates the last line
|
||||
* rather than adding an empty one. An interior blank line survives. Keeping the
|
||||
* two front ends on the same rule holds their `+A -R` footers in step.
|
||||
* applies: empty text is zero lines, a trailing newline terminates the last
|
||||
* line, and an interior blank line survives.
|
||||
*/
|
||||
function diffContentLines(text: string): string[] {
|
||||
if (text === '') return []
|
||||
@@ -65,16 +71,47 @@ function diffContentLines(text: string): string[] {
|
||||
return body.split('\n')
|
||||
}
|
||||
|
||||
/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */
|
||||
function diffLines(diff: FileDiff, palette: Palette): string[] {
|
||||
/**
|
||||
* A file diff whose unchanged context stays neutral and does not affect exact
|
||||
* change totals. Comparisons beyond the edit-distance budget fall back to
|
||||
* whole-side rendering so a model-authored pending edit cannot stall the TUI.
|
||||
*/
|
||||
function renderDiff(diff: FileDiff, maxDiffEditLength: number, palette: Palette): RenderedDiff {
|
||||
// The card header is a fixed `Tool / <name>` frame that never names a file, so
|
||||
// each hunk always carries its own path header (no redundancy to suppress).
|
||||
const lines = [palette.bold(displayText(diff.path))]
|
||||
if (diff.oldText !== null) {
|
||||
for (const line of diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`))
|
||||
let added = 0
|
||||
let removed = 0
|
||||
if (diff.oldText === null) {
|
||||
const newLines = diffContentLines(displayText(diff.newText))
|
||||
added = newLines.length
|
||||
for (const line of newLines) lines.push(palette.success(`+ ${line}`))
|
||||
return { lines, added, removed, approximate: false }
|
||||
}
|
||||
for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`))
|
||||
return lines
|
||||
const changes = compareLines(diff.oldText, diff.newText, { maxEditLength: maxDiffEditLength })
|
||||
if (changes === undefined) {
|
||||
const oldLines = diffContentLines(displayText(diff.oldText))
|
||||
const newLines = diffContentLines(displayText(diff.newText))
|
||||
lines.push(palette.dim(`[exact line diff omitted: >${maxDiffEditLength} changed lines]`))
|
||||
removed = oldLines.length
|
||||
added = newLines.length
|
||||
for (const line of oldLines) lines.push(palette.error(`- ${line}`))
|
||||
for (const line of newLines) lines.push(palette.success(`+ ${line}`))
|
||||
return { lines, added, removed, approximate: true }
|
||||
}
|
||||
for (const change of changes) {
|
||||
const changedLines = diffContentLines(displayText(change.value))
|
||||
if (change.added) {
|
||||
added += changedLines.length
|
||||
for (const line of changedLines) lines.push(palette.success(`+ ${line}`))
|
||||
} else if (change.removed) {
|
||||
removed += changedLines.length
|
||||
for (const line of changedLines) lines.push(palette.error(`- ${line}`))
|
||||
} else {
|
||||
for (const line of changedLines) lines.push(palette.dim(` ${line}`))
|
||||
}
|
||||
}
|
||||
return { lines, added, removed, approximate: false }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,12 +415,14 @@ export class ToolCardComponent implements Component {
|
||||
private visibility: ToolCardVisibility = 'collapsed'
|
||||
private callView: ToolCallView
|
||||
private resultView: ToolResultView | undefined
|
||||
private diffBodyCache: { view: ToolCallView | ToolResultView; body: CardBody } | undefined
|
||||
|
||||
constructor(
|
||||
private readonly name: string,
|
||||
private readonly parsed: ParsedArguments,
|
||||
private readonly definition: ToolDefinition | undefined,
|
||||
private readonly maxOutputLines: number,
|
||||
private readonly maxDiffEditLength: number,
|
||||
private readonly palette: Palette,
|
||||
private readonly mdTheme: MarkdownTheme,
|
||||
) {
|
||||
@@ -407,6 +446,7 @@ export class ToolCardComponent implements Component {
|
||||
* @param event - The `tool/result` event payload.
|
||||
*/
|
||||
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
|
||||
this.diffBodyCache = undefined
|
||||
const result = event.message.content[0]
|
||||
this.result = {
|
||||
content: [...result.content],
|
||||
@@ -560,24 +600,28 @@ export class ToolCardComponent implements Component {
|
||||
return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) }
|
||||
}
|
||||
if (view.card === 'diff') {
|
||||
if (this.diffBodyCache?.view === view) return this.diffBodyCache.body
|
||||
// The header no longer names the file, so each diff keeps its own path
|
||||
// header. A trailing footer summarizes the change (`+A -R · N file(s)`),
|
||||
// on the same terminator rule and distinct-path count the Web DiffBlock
|
||||
// uses, so the two front ends' footers agree.
|
||||
let added = 0
|
||||
let removed = 0
|
||||
const paths = new Set<string>()
|
||||
const hunks = view.diffs.flatMap((diff, index) => {
|
||||
paths.add(diff.path)
|
||||
if (diff.oldText !== null) removed += diffContentLines(displayText(diff.oldText)).length
|
||||
added += diffContentLines(displayText(diff.newText)).length
|
||||
return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)]
|
||||
// header. A trailing footer summarizes the exact changed rows when the
|
||||
// bounded comparison succeeds (`+A -R · N file(s)`).
|
||||
const renderedDiffs = view.diffs.map(diff =>
|
||||
renderDiff(diff, this.maxDiffEditLength, this.palette),
|
||||
)
|
||||
const added = renderedDiffs.reduce((total, rendered) => total + rendered.added, 0)
|
||||
const removed = renderedDiffs.reduce((total, rendered) => total + rendered.removed, 0)
|
||||
const approximate = renderedDiffs.some(rendered => rendered.approximate)
|
||||
const hunks = renderedDiffs.flatMap((rendered, index) => {
|
||||
return [...index > 0 ? [''] : [], ...rendered.lines]
|
||||
})
|
||||
const files = paths.size
|
||||
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)
|
||||
const files = new Set(view.diffs.map(diff => diff.path)).size
|
||||
const footer = this.palette.dim(
|
||||
`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}${approximate ? ' · approximate' : ''}`,
|
||||
)
|
||||
// A diff's own `+`/`-` colors carry its meaning, so it renders verbatim
|
||||
// rather than under the dim result-output color.
|
||||
return { prelude: [...hunks, footer], lines: [] }
|
||||
const body = { prelude: [...hunks, footer], lines: [] }
|
||||
this.diffBodyCache = { view, body }
|
||||
return body
|
||||
}
|
||||
// A generic or read card carries its own envelope-stripped `content`; a
|
||||
// search or web card carries no `content` copy and falls back to the raw
|
||||
|
||||
@@ -34,12 +34,16 @@ export interface TuiConfig {
|
||||
showReasoning?: boolean
|
||||
/** Maximum tool-card body lines retained in its collapsed head/tail preview. */
|
||||
maxToolOutputLines?: number
|
||||
/** Maximum added and removed lines explored while deriving an exact line diff. */
|
||||
maxDiffEditLength?: number
|
||||
/** Maximum options visible at once in a user-question panel. */
|
||||
maxQuestionOptions?: number
|
||||
/** Maximum models visible at once in the model selector. */
|
||||
maxModelOptions?: number
|
||||
/** Maximum sessions visible at once in the resume selector. */
|
||||
maxResumeOptions?: number
|
||||
/** Maximum concurrent cold projection reads in one resume scan. */
|
||||
resumeScanConcurrency?: number
|
||||
/** User-question panel width in terminal columns, clamped to the terminal. */
|
||||
questionDialogWidth?: number
|
||||
/** User-question panel maximum height in terminal rows. */
|
||||
@@ -66,9 +70,11 @@ export interface TuiConfig {
|
||||
|
||||
const showReasoningSchema = z.boolean().default(true)
|
||||
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
|
||||
const maxDiffEditLengthSchema = z.number().step(1).min(1).default(1000)
|
||||
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const resumeScanConcurrencySchema = z.number().step(1).min(1).default(4)
|
||||
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
|
||||
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const modelDialogWidthSchema = z.number().step(1).min(20).default(76)
|
||||
@@ -98,9 +104,11 @@ const titleSchema = z.string().default('DeepSeek Harness')
|
||||
const tuiConfigSchemaFields = {
|
||||
showReasoning: showReasoningSchema,
|
||||
maxToolOutputLines: maxToolOutputLinesSchema,
|
||||
maxDiffEditLength: maxDiffEditLengthSchema,
|
||||
maxQuestionOptions: maxQuestionOptionsSchema,
|
||||
maxModelOptions: maxModelOptionsSchema,
|
||||
maxResumeOptions: maxResumeOptionsSchema,
|
||||
resumeScanConcurrency: resumeScanConcurrencySchema,
|
||||
questionDialogWidth: questionDialogWidthSchema,
|
||||
questionDialogMaxHeight: questionDialogMaxHeightSchema,
|
||||
modelDialogWidth: modelDialogWidthSchema,
|
||||
@@ -139,6 +147,7 @@ export const Config: z<Config> = z.object({
|
||||
initialSkill: z.string(),
|
||||
showReasoning: tuiConfigSchemaFields.showReasoning,
|
||||
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
|
||||
maxDiffEditLength: tuiConfigSchemaFields.maxDiffEditLength,
|
||||
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
|
||||
maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
|
||||
maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions,
|
||||
@@ -169,9 +178,11 @@ export interface ResolvedTuiThemeConfig {
|
||||
export interface ResolvedTuiConfig {
|
||||
showReasoning: boolean
|
||||
maxToolOutputLines: number
|
||||
maxDiffEditLength: number
|
||||
maxQuestionOptions: number
|
||||
maxModelOptions: number
|
||||
maxResumeOptions: number
|
||||
resumeScanConcurrency: number
|
||||
questionDialogWidth: number
|
||||
questionDialogMaxHeight: number
|
||||
modelDialogWidth: number
|
||||
@@ -195,9 +206,11 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
|
||||
return {
|
||||
showReasoning: config?.showReasoning ?? true,
|
||||
maxToolOutputLines: config?.maxToolOutputLines ?? 6,
|
||||
maxDiffEditLength: config?.maxDiffEditLength ?? 1000,
|
||||
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
|
||||
maxModelOptions: config?.maxModelOptions ?? 8,
|
||||
maxResumeOptions: config?.maxResumeOptions ?? 8,
|
||||
resumeScanConcurrency: config?.resumeScanConcurrency ?? 4,
|
||||
questionDialogWidth: config?.questionDialogWidth ?? 200,
|
||||
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
|
||||
modelDialogWidth: config?.modelDialogWidth ?? 76,
|
||||
|
||||
@@ -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<TuiOverlayOutcome>
|
||||
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<TuiOverlayCloseReason, 'error'>): Promise<TuiOverlayOutcome>
|
||||
} {
|
||||
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) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ProcessTerminal,
|
||||
matchesKey,
|
||||
visibleWidth,
|
||||
type Component,
|
||||
type EditorTheme,
|
||||
type SlashCommand,
|
||||
type TerminalColorScheme,
|
||||
@@ -289,6 +290,23 @@ interface FadingStatus {
|
||||
timer: ReturnType<typeof setInterval>
|
||||
}
|
||||
|
||||
/** 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)
|
||||
@@ -643,6 +681,7 @@ export function createTuiChat(
|
||||
parsed,
|
||||
ctx.tools.get(event.data.name, agent),
|
||||
resolved.maxToolOutputLines,
|
||||
resolved.maxDiffEditLength,
|
||||
palette,
|
||||
mdTheme,
|
||||
)
|
||||
@@ -835,7 +874,15 @@ export function createTuiChat(
|
||||
const callId = event.data.message.source.callId
|
||||
let card = toolCards.get(callId)
|
||||
if (card === undefined) {
|
||||
card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme)
|
||||
card = new ToolCardComponent(
|
||||
'tool',
|
||||
{ value: {}, valid: true },
|
||||
undefined,
|
||||
resolved.maxToolOutputLines,
|
||||
resolved.maxDiffEditLength,
|
||||
palette,
|
||||
mdTheme,
|
||||
)
|
||||
card.setVisibility(toolsVisibility)
|
||||
chat.addChild(card)
|
||||
allToolCards.add(card)
|
||||
@@ -947,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({
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface TuiHarnessOptions {
|
||||
sessionPersistence?: {
|
||||
list(): Promise<SessionHeader[]>
|
||||
load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
|
||||
/** Per-session artifact location for mtime-based activity; defaults to none. */
|
||||
locate?(meta: SessionHeader): { kind: string; path: string } | undefined
|
||||
}
|
||||
handoffResume?: TuiRuntime['handoffResume']
|
||||
/** Host-supplied exit line; absent exercises the no-message path. */
|
||||
@@ -156,7 +158,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
const persistence = options.sessionPersistence
|
||||
ctx.provide('sessionPersistence', {
|
||||
...persistence,
|
||||
locate: () => undefined,
|
||||
locate: (meta: SessionHeader) => persistence.locate?.(meta),
|
||||
create: () => Promise.resolve(),
|
||||
append: () => Promise.resolve(),
|
||||
load: persistence.load === undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 100x40 buffer=normal length=40 base=0 viewport=0
|
||||
terminal 100x40 buffer=normal length=41 base=1 viewport=1
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=34 bufferRow=34
|
||||
cursor hidden column=7 viewportRow=39 bufferRow=40
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
@@ -31,9 +31,9 @@ buffer
|
||||
style 0-10 bold
|
||||
14| "- old line "
|
||||
style 0-9 fg=red
|
||||
15| "… +3 lines (Ctrl+O to expand) "
|
||||
15| "… +2 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
16| "└ +2 -2 · 1 file "
|
||||
16| "└ +1 -1 · 1 file "
|
||||
style 0-15 dim
|
||||
17| <blank>
|
||||
18| "● Tool / subagent"
|
||||
@@ -58,17 +58,27 @@ buffer
|
||||
style 0-99 dim
|
||||
30| "Loaded review instructions. "
|
||||
style 0-99 dim
|
||||
31| "Model wait 0.0s "
|
||||
31| <blank>
|
||||
32| "● Tool / large_edit"
|
||||
style 0-18 fg=green
|
||||
33| "src/large.ts "
|
||||
style 0-11 bold
|
||||
34| "[exact line diff omitted: >2 changed lines] "
|
||||
style 0-42 dim
|
||||
35| "… +6 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
36| "└ +3 -3 · 1 file · approximate "
|
||||
style 0-29 dim
|
||||
37| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
32| <blank>
|
||||
33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
38| <blank>
|
||||
39| "/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
|
||||
34| " dsh > "
|
||||
40| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
35-39| <blank>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 100x40 buffer=normal length=43 base=3 viewport=3
|
||||
terminal 100x40 buffer=normal length=53 base=13 viewport=13
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=7 viewportRow=39 bufferRow=42
|
||||
cursor hidden column=7 viewportRow=39 bufferRow=52
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-magenta bold
|
||||
@@ -37,54 +37,73 @@ buffer
|
||||
style 0-10 bold
|
||||
17| "- old line "
|
||||
style 0-9 fg=red
|
||||
18| "- keep "
|
||||
style 0-5 fg=red
|
||||
19| "+ new line "
|
||||
18| "+ new line "
|
||||
style 0-9 fg=green
|
||||
20| "+ keep "
|
||||
style 0-5 fg=green
|
||||
21| "└ +2 -2 · 1 file "
|
||||
19| " keep "
|
||||
style 0-5 dim
|
||||
20| "└ +1 -1 · 1 file "
|
||||
style 0-15 dim
|
||||
22| <blank>
|
||||
23| "● Tool / subagent"
|
||||
21| <blank>
|
||||
22| "● Tool / subagent"
|
||||
style 0-16 fg=green
|
||||
24| "Delegate renderer audit "
|
||||
23| "Delegate renderer audit "
|
||||
style 0-99 dim
|
||||
25| "The renderer has explicit lifecycle ownership. "
|
||||
24| "The renderer has explicit lifecycle ownership. "
|
||||
style 0-99 dim
|
||||
26| <blank>
|
||||
27| "● Tool / task_output"
|
||||
25| <blank>
|
||||
26| "● Tool / task_output"
|
||||
style 0-19 fg=green
|
||||
28| "Read output from background task subagent-7 "
|
||||
27| "Read output from background task subagent-7 "
|
||||
style 0-99 dim
|
||||
29| " "
|
||||
30| "console "
|
||||
28| " "
|
||||
29| "console "
|
||||
style 0-6 dim
|
||||
31| " started background task bash-5 "
|
||||
30| " started background task bash-5 "
|
||||
style 0-1 dim
|
||||
style 2-31 fg=cyan dim
|
||||
style 32-99 dim
|
||||
32| " "
|
||||
33| <blank>
|
||||
34| "● Tool / skill"
|
||||
31| " "
|
||||
32| <blank>
|
||||
33| "● Tool / skill"
|
||||
style 0-13 fg=green
|
||||
35| "Load skill dsh-code-review "
|
||||
34| "Load skill dsh-code-review "
|
||||
style 0-99 dim
|
||||
36| "Loaded review instructions. "
|
||||
35| "Loaded review instructions. "
|
||||
style 0-99 dim
|
||||
37| "Model wait 0.0s "
|
||||
36| <blank>
|
||||
37| "● Tool / large_edit"
|
||||
style 0-18 fg=green
|
||||
38| "src/large.ts "
|
||||
style 0-11 bold
|
||||
39| "[exact line diff omitted: >2 changed lines] "
|
||||
style 0-42 dim
|
||||
40| "- old one "
|
||||
style 0-8 fg=red
|
||||
41| "- old two "
|
||||
style 0-8 fg=red
|
||||
42| "- old three "
|
||||
style 0-10 fg=red
|
||||
43| "+ new one "
|
||||
style 0-8 fg=green
|
||||
44| "+ new two "
|
||||
style 0-8 fg=green
|
||||
45| "+ new three "
|
||||
style 0-10 fg=green
|
||||
46| "└ +3 -3 · 1 file · approximate "
|
||||
style 0-29 dim
|
||||
47| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
38| <blank>
|
||||
39| "Tool and context cards expanded. "
|
||||
48| <blank>
|
||||
49| "Tool and context cards expanded. "
|
||||
style 0-31 dim
|
||||
40| <blank>
|
||||
41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
50| <blank>
|
||||
51| "/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
|
||||
42| " dsh > "
|
||||
52| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
|
||||
@@ -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| <blank>
|
||||
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
|
||||
@@ -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| <blank>
|
||||
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
|
||||
@@ -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| <blank>
|
||||
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| <blank>
|
||||
17-19| <blank>
|
||||
|
||||
@@ -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| <blank>
|
||||
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| <blank>
|
||||
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| <blank>
|
||||
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
|
||||
|
||||
@@ -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| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
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| <blank>
|
||||
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
|
||||
|
||||
@@ -22,28 +22,25 @@ buffer
|
||||
8| " "
|
||||
9| " ❯ Untitled session "
|
||||
style 2-19 fg=bright-magenta bold
|
||||
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
|
||||
style 2-67 dim
|
||||
11| " current · live · main-session "
|
||||
style 2-32 dim
|
||||
12| " workspace /workspace/project "
|
||||
10| " 2026-07-23T08:00:00.000Z · current · live · main-session "
|
||||
style 2-59 dim
|
||||
11| " workspace /workspace/project "
|
||||
style 2-31 dim
|
||||
13| " unavailable: current session "
|
||||
12| " unavailable: current session "
|
||||
style 2-31 fg=yellow
|
||||
14| " Other workspace work "
|
||||
15| " 2024-02-02T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
|
||||
style 2-83 dim
|
||||
16| " persisted · elsewhere-session "
|
||||
style 2-32 dim
|
||||
17| " workspace /workspace/other "
|
||||
13| " Other workspace work "
|
||||
14| " 2024-02-02T00:00:00.000Z · persisted · elsewhere-session "
|
||||
style 2-59 dim
|
||||
15| " workspace /workspace/other "
|
||||
style 2-29 dim
|
||||
18| " Resume selector design "
|
||||
19| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
|
||||
style 2-83 dim
|
||||
20| " persisted · earlier-session "
|
||||
style 2-30 dim
|
||||
21| " workspace /workspace/project "
|
||||
16| " Resume selector design "
|
||||
17| " 2024-01-01T00:00:00.000Z · persisted · earlier-session "
|
||||
style 2-57 dim
|
||||
18| " workspace /workspace/project "
|
||||
style 2-31 dim
|
||||
19| " "
|
||||
20| " "
|
||||
21| " "
|
||||
22| " "
|
||||
23| " "
|
||||
24| " "
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=6 viewportRow=4 bufferRow=4
|
||||
buffer
|
||||
0| " "
|
||||
1| " Resume session "
|
||||
style 2-15 fg=bright-magenta bold
|
||||
2| " "
|
||||
3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ "
|
||||
style 2-89 dim
|
||||
4| " │ ⌕ │ "
|
||||
style 2-2 dim
|
||||
style 6-6 inverse
|
||||
style 89-89 dim
|
||||
5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
|
||||
style 2-89 dim
|
||||
6| " "
|
||||
7| " this workspace /workspace/project ⇥ all workspaces (0) "
|
||||
style 2-34 fg=bright-magenta
|
||||
style 35-56 dim
|
||||
8| " "
|
||||
9| " Loading sessions… "
|
||||
style 2-18 dim
|
||||
10| " "
|
||||
11| " "
|
||||
12| " "
|
||||
13| " "
|
||||
14| " "
|
||||
15| " "
|
||||
16| " "
|
||||
17| " "
|
||||
18| " "
|
||||
19| " "
|
||||
20| " "
|
||||
21| " "
|
||||
22| " "
|
||||
23| " "
|
||||
24| " "
|
||||
25| " "
|
||||
26| " "
|
||||
27| " "
|
||||
28| " "
|
||||
29| " "
|
||||
30| " Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel "
|
||||
style 2-84 dim
|
||||
31| " "
|
||||
@@ -22,17 +22,15 @@ buffer
|
||||
8| " "
|
||||
9| " ❯ Untitled session "
|
||||
style 2-19 fg=bright-magenta bold
|
||||
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
|
||||
style 2-67 dim
|
||||
11| " current · live · main-session "
|
||||
style 2-32 dim
|
||||
12| " unavailable: current session "
|
||||
10| " 2026-07-23T08:00:00.000Z · current · live · main-session "
|
||||
style 2-59 dim
|
||||
11| " unavailable: current session "
|
||||
style 2-31 fg=yellow
|
||||
13| " Resume selector design "
|
||||
14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
|
||||
style 2-83 dim
|
||||
15| " persisted · earlier-session "
|
||||
style 2-30 dim
|
||||
12| " Resume selector design "
|
||||
13| " 2024-01-01T00:00:00.000Z · persisted · earlier-session "
|
||||
style 2-57 dim
|
||||
14| " "
|
||||
15| " "
|
||||
16| " "
|
||||
17| " "
|
||||
18| " "
|
||||
|
||||
@@ -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| <blank>
|
||||
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| <blank>
|
||||
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| <blank>
|
||||
38| " "
|
||||
39| " dsh > "
|
||||
style 1-3 fg=bright-magenta bold
|
||||
style 5-6 dim
|
||||
style 7-7 inverse
|
||||
|
||||
@@ -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',
|
||||
@@ -60,6 +62,7 @@ const CHECKPOINTS = [
|
||||
'model-switching',
|
||||
'errors-and-help',
|
||||
'disposed-terminal',
|
||||
'resume-sessions-loading',
|
||||
'resume-sessions',
|
||||
'resume-sessions-all-workspaces',
|
||||
'status-diagnostics',
|
||||
@@ -272,13 +275,32 @@ const ADVANCED_CARD_TOOLS: Record<string, ToolDefinition> = {
|
||||
edit: visualTool(
|
||||
'edit',
|
||||
() => ({ card: 'diff', title: 'Edit src/view.ts', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }),
|
||||
// The real edit/write tools produce exactly one diff whose path the title
|
||||
// already names, so the card omits the redundant per-file header.
|
||||
// The fixed tool header never names a path, so the hunk retains its path.
|
||||
(): ToolResultView => ({
|
||||
card: 'diff',
|
||||
diffs: [{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }],
|
||||
}),
|
||||
),
|
||||
large_edit: visualTool(
|
||||
'large_edit',
|
||||
() => ({
|
||||
card: 'diff',
|
||||
title: 'Edit src/large.ts',
|
||||
diffs: [{
|
||||
path: 'src/large.ts',
|
||||
oldText: 'old one\nold two\nold three',
|
||||
newText: 'new one\nnew two\nnew three',
|
||||
}],
|
||||
}),
|
||||
(): ToolResultView => ({
|
||||
card: 'diff',
|
||||
diffs: [{
|
||||
path: 'src/large.ts',
|
||||
oldText: 'old one\nold two\nold three',
|
||||
newText: 'new one\nnew two\nnew three',
|
||||
}],
|
||||
}),
|
||||
),
|
||||
subagent: visualTool('subagent', args => ({
|
||||
card: 'generic',
|
||||
title: 'Delegate renderer audit',
|
||||
@@ -588,7 +610,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
it('pins terminal, diff, subagent, task, skill, collapsed, and expanded cards', async () => {
|
||||
const harness = await setupSnapshot({
|
||||
tools: ADVANCED_CARD_TOOLS,
|
||||
config: { maxToolOutputLines: 3 },
|
||||
config: { maxToolOutputLines: 3, maxDiffEditLength: 2 },
|
||||
}, { columns: 100, rows: 40 })
|
||||
const calls = [
|
||||
{ id: 'advanced-1', name: 'bash', arguments: { command: 'pnpm run test:coverage' } },
|
||||
@@ -596,6 +618,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
{ id: 'advanced-3', name: 'subagent', arguments: { prompt: 'Review renderer ownership and report only gaps.' } },
|
||||
{ id: 'advanced-4', name: 'task_output', arguments: { task_id: 'subagent-7', wait: true } },
|
||||
{ id: 'advanced-5', name: 'skill', arguments: { name: 'dsh-code-review' } },
|
||||
{ id: 'advanced-6', name: 'large_edit', arguments: { file_path: 'src/large.ts' } },
|
||||
]
|
||||
await renderAfter(harness, () => {
|
||||
appendToolCalls(harness.session, calls)
|
||||
@@ -604,6 +627,7 @@ describe('TUI terminal-state snapshots', () => {
|
||||
appendToolResult(harness.session, 'advanced-3', [{ type: 'text', text: 'The renderer has explicit lifecycle ownership.' }])
|
||||
appendToolResult(harness.session, 'advanced-4', [{ type: 'text', text: 'audit complete\n[status: completed]' }])
|
||||
appendToolResult(harness.session, 'advanced-5', [{ type: 'text', text: 'Loaded review instructions.' }])
|
||||
appendToolResult(harness.session, 'advanced-6', [{ type: 'text', text: 'large edit complete' }])
|
||||
})
|
||||
await checkpoint('advanced-cards-collapsed', harness.terminal, { includeScrollback: true })
|
||||
|
||||
@@ -765,9 +789,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' },
|
||||
@@ -782,6 +810,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()
|
||||
@@ -946,14 +982,18 @@ describe('TUI terminal-state snapshots', () => {
|
||||
{ type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
|
||||
// A prior pickup, dated well after the work: the picker must still
|
||||
// show the work's date, not the pickup's.
|
||||
{ type: 'session/end-seed', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} },
|
||||
],
|
||||
})
|
||||
const listGate = Promise.withResolvers<undefined>()
|
||||
// Rows show metadata activity (here the created-at fallback: the fake
|
||||
// store locates no per-session artifact to stat) plus each log's one
|
||||
// batch-folded title; nothing else is read from the logs.
|
||||
const harness = await setupSnapshot({
|
||||
sessionPersistence: {
|
||||
list: async () => [earlier, elsewhere],
|
||||
list: async () => {
|
||||
await listGate.promise
|
||||
return [earlier, elsewhere]
|
||||
},
|
||||
load: async id => id === elsewhere.id
|
||||
? log(elsewhere, 'Other workspace work', '2024-02-02')
|
||||
: log(earlier, 'Resume selector design', '2024-01-01'),
|
||||
@@ -961,8 +1001,15 @@ describe('TUI terminal-state snapshots', () => {
|
||||
}, { columns: 92, rows: 32 })
|
||||
harness.terminal.send('/resume')
|
||||
harness.terminal.send('\r')
|
||||
// `/resume` scans persistence asynchronously, so the listing renders a tick
|
||||
// after submit (the unit suite waits the same way); settle, then flush.
|
||||
// The picker opens as soon as the command dispatches and owns input while
|
||||
// the persistence scan is still pending, rendering a loading placeholder
|
||||
// in place of rows; only the scan is gated, so this settle never lists.
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
await harness.terminal.flush()
|
||||
await checkpoint('resume-sessions-loading', harness.terminal, { includeScrollback: true })
|
||||
listGate.resolve(undefined)
|
||||
// With the scan released, the listing renders a tick later (the unit suite
|
||||
// waits the same way); settle, then flush.
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
await harness.terminal.flush()
|
||||
await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,12 @@
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection-cache"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
|
||||
@@ -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/user-interaction/README.md
|
||||
README.md: d62e75d110b8be339c5f9449b0834320f695ac99
|
||||
README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816
|
||||
README.md: c7fec590d6e44a13b94cc682f5e069b2d3c5e416
|
||||
README.zh.md: 340af3541a09a528aa0fcc580bda070ea703f39e
|
||||
|
||||
@@ -20,7 +20,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
|
||||
- `UserInteractionProvider` — UI implementation with `ask(request)`.
|
||||
- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
|
||||
|
||||
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
|
||||
For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
|
||||
|
||||
### Presentation intent
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
|
||||
- `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。
|
||||
|
||||
当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
|
||||
对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
|
||||
|
||||
### 呈现意图
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface AskUserQuestionItem {
|
||||
export interface AskUserQuestionAnswerItem {
|
||||
/** The answered question id. */
|
||||
id: string
|
||||
/** Selected option labels. Empty for custom or unanswered choices. */
|
||||
/** Selected option labels. May accompany custom text for a multi-select question. */
|
||||
selected: string[]
|
||||
/** Optional free-text "Other" answer. */
|
||||
custom?: string
|
||||
|
||||
Reference in New Issue
Block a user