Merge remote-tracking branch 'origin/master' into feat/py-types-code-mode

This commit is contained in:
Chinesezjc
2026-07-31 20:17:40 +08:00
161 changed files with 3483 additions and 529 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 64982c2b5af891b60055a41bc3c30c1ba4041300
README.zh.md: 2cc2dc30bd4913c52645c76de4bc55d109a40001
README.md: 0ae71ff17b13be67c16786ff69a0e1626437913a
README.zh.md: 52a443d9df753ba01650b6cbf189c39633f6a461

View File

@@ -44,7 +44,7 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
## Model retry projection
The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node.
The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay apply the same projection, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted assistant node beside the terminal error.
## Session forking

View File

@@ -44,7 +44,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 模型重试投影
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose(资源释放)时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。
Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose(资源释放)时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
## 会话 fork

View File

@@ -47,7 +47,7 @@ export type {
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
ConversationContext, ConversationContextOriginKind,

View File

@@ -135,6 +135,19 @@ export type ModelRetryNode = LlmRetryEventData & {
retryState: 'scheduled' | 'started' | 'cancelled'
}
/** Durable terminal failure for a turn that has no scheduled retry. */
export interface TurnErrorNode {
kind: 'turn-error'
/** Seq of the owning turn/end event. */
seq: number
/** Unix epoch ms from the turn/end event. */
time: number
turn: number
step: number
message: string
code?: string
}
/** A tool result paired (when in-window) with its call head. */
export interface ToolResultNode {
kind: 'tool-result'
@@ -223,6 +236,7 @@ export type ConversationNode =
| SteeringMessageNode
| ContextMessageNode
| ModelRetryNode
| TurnErrorNode
| ToolResultNode
| CommandNode
| CompactionSummaryNode

View File

@@ -0,0 +1,10 @@
/**
* Convert a durable failure into copy that is safe to expose in the GUI.
* @param failure - Structured failure preserved by the session event.
* @returns Display-safe copy for client projections.
*/
export function displayFailureMessage(failure: { code?: string; message: string }): string {
// Provider AUTH messages may echo a masked or partially preserved credential.
// Keep the raw diagnostic in the session log, but never project it into UI state.
return failure.code === 'AUTH' ? 'API key is invalid' : failure.message
}

View File

@@ -8,6 +8,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
AssistantProvenanceView, AssistantRequestConfig,
} from './conversation.ts'
import { displayFailureMessage } from './failure-display.ts'
export type {
AssistantProvenanceView, AssistantRequestConfig,
@@ -350,7 +351,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
const event = sourceEvent as unknown as RetryEvent
updateAssistant(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
status: 'error',
error: event.data.failure.message,
error: displayFailureMessage(event.data.failure),
retry: event.data.retry,
maxRetries: event.data.maxRetries,
retryDelayMs: event.data.delayMs,
@@ -361,7 +362,7 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[]
const reason = sourceEvent.data.reason
updateAssistant(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
status: 'error',
error: 'failure' in reason ? reason.failure.message : reason.message,
error: displayFailureMessage('failure' in reason ? reason.failure : reason),
})
continue
}

View File

@@ -19,6 +19,7 @@ import type {
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { TranscriptAdapter } from './transcript-adapter.ts'
import { displayFailureMessage } from './failure-display.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
@@ -749,6 +750,22 @@ export class Session implements SessionFace {
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
this.settleScheduledRetry('cancelled', event.data.turn)
}
if (
event.data.reason.kind === 'error'
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
) {
const failure = 'failure' in event.data.reason ? event.data.reason.failure : event.data.reason
this.derivedNodes.push({
kind: 'turn-error',
seq: event.seq,
time: event.time,
turn: event.data.turn,
step: event.data.reason.step,
message: displayFailureMessage(failure),
...(failure.code === undefined ? {} : { code: failure.code }),
})
this.derivedRev++
}
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node

View File

@@ -221,6 +221,33 @@ describe('inspectRequests', () => {
})
})
it('keeps provider credential fragments out of projected request errors', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),
at(1, 'turn/end', {
turn: 1,
reason: {
kind: 'error',
step: 1,
failure: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
}),
at(2, 'step/start', { turn: 2, step: 1 }),
at(3, 'turn/end', {
turn: 2,
reason: { kind: 'error', step: 1, message: 'plugin exploded' },
}),
]))
expect(snapshot.requests).toMatchObject([
{ status: 'error', error: 'API key is invalid' },
{ status: 'error', error: 'plugin exploded' },
])
})
it('treats a scrubbed durable-fixture tool catalog as unavailable', () => {
const snapshot = inspectRequests(entriesOf([
at(0, 'step/start', { turn: 1, step: 1 }),

View File

@@ -211,6 +211,7 @@ describe('live event path', () => {
for (const event of retryTurn.slice(7)) feed(event)
snapshot = session.getSnapshot()
expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
@@ -221,6 +222,50 @@ describe('live event path', () => {
expect(replay.session.getSnapshot().partial).toBeNull()
})
it('projects unretried terminal failures at turn/end and reproduces them from history', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
const failedTurns = [
ev.turnStart(6, 1),
ev.user(7, '鉴权失败'),
at(8, {
type: 'turn/end',
data: {
turn: 1,
reason: {
kind: 'error',
step: 0,
failure: {
code: 'AUTH',
message: 'Authentication Fails, Your api key: sk-preview-secret is invalid',
},
},
},
}),
ev.turnStart(9, 2),
ev.user(10, '内部失败'),
at(11, {
type: 'turn/end',
data: { turn: 2, reason: { kind: 'error', step: 1, message: 'plugin exploded' } },
}),
]
for (const event of failedTurns) feed(event)
const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error')
expect(errors).toMatchObject([
{ seq: 8, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
{ seq: 11, turn: 2, step: 1, message: 'plugin exploded' },
])
expect('code' in errors[1]!).toBe(false)
const replay = makeSession()
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns])
await replay.session.open()
expect(replay.session.getSnapshot().nodes).toEqual(session.getSnapshot().nodes)
})
it('rejects retry payloads outside the producer contract without retracting the current partial', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: f7279d2c640d447609c4e9804af633d026b35ed9
README.zh.md: 2e8f3483c160689cdf5ff283f2955a5c0be4fd8e
README.md: 68115f8f9b9225d1f6b0e9cc93394d042c2befaa
README.zh.md: 65a3b334ff316a37dfb8506396eae8c17cccb40d

View File

@@ -24,7 +24,7 @@ A `read` call declaring the `read` render intent renders the returned file windo
A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) composes the shared `ToolRow`, feeding the diff as ToolRow's `diff` body, so it is the row's collapsed-by-default expanded card; the summary path link still opens the file through the host, and an errored mutation (no diff card) surfaces its error text through ToolRow's Output section with the first line in the collapsed summary. The render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments.
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
@@ -61,4 +61,4 @@ None; this package neither assembles nor sends a provider request.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
- **Web exposes pending Queue only** — the Host omits pending steering from the Queue snapshot until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay.
- **Web exposes pending Queue only** — the composer and `conversation.send` never submit `mode:'steer'`. The Host omits pending steering from the Queue snapshot. A consumed `steering/message` still folds into the durable transcript as a plain bubble (no interjection chrome) so external/host steering remains truthful on replay.

View File

@@ -22,7 +22,7 @@
声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)组合共享的 `ToolRow`,把 diff 作为 ToolRow 的 `diff` body 传入,因此它是该行默认折叠的展开卡片;摘要路径链接仍经 host 打开文件,而出错的改动(没有 diff 卡片)经 ToolRow 的 Output 区呈现其错误文本,首行进入折叠摘要。渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。
声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
@@ -61,4 +61,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。
- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript 中,因此从外部提交的 steering 在回放时仍能如实呈现。
- **Web 仅暴露待处理 Queue**:composer 与 `conversation.send` 从不提交 `mode:'steer'`。Host 不会把待处理 steering(中途引导)纳入 Queue 快照。已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以无「插话」徽章的普通气泡呈现,因此从外部/Host 提交的 steering 在回放时仍能如实呈现。

View File

@@ -20,16 +20,6 @@
color: var(--dsw-alias-label-primary);
}
.badge {
display: inline-block;
margin-bottom: 4px;
padding: 1px 6px;
border-radius: 6px;
background: var(--dsw-alias-state-warn-primary);
color: var(--dsw-alias-label-primary-foreground);
font-size: 11px;
}
.contextRow {
padding: 2px 0;
}
@@ -191,6 +181,40 @@
color: var(--dsw-alias-label-secondary);
}
.turnErrorRow {
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
padding: 2px 0;
font-size: 13px;
line-height: 20px;
}
.turnErrorDot {
margin-top: 5px;
}
.turnErrorCopy {
min-width: 0;
overflow-wrap: anywhere;
}
.turnErrorTitle {
margin-right: 6px;
color: var(--dsw-alias-state-error-primary);
font-weight: 600;
}
.turnErrorMessage {
color: var(--dsw-alias-label-secondary);
}
.turnErrorCode {
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-markdown-code-block-small);
}
@keyframes retry-shimmer {
from {
background-position: 100% 50%;

View File

@@ -1,14 +1,15 @@
// MessageItem: simple chat nodes — user bubble (right-aligned, with
// clock + copy / branch IconActions), steering (badged bubble), context
// injection, compaction marker, retry disclosure, and unknown-surface JSON rows.
// clock + copy / branch IconActions), steering (same bubble, no actions),
// context injection, compaction marker, retry disclosure, and
// unknown-surface JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
UnknownSurfaceNode, UserMessageNode,
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { CompactionItem } from './CompactionItem.tsx'
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
@@ -16,7 +17,14 @@ import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
export interface MessageItemProps {
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | CompactionSummaryNode | ModelRetryNode | UnknownSurfaceNode
node:
| UserMessageNode
| SteeringMessageNode
| ContextMessageNode
| CompactionSummaryNode
| ModelRetryNode
| TurnErrorNode
| UnknownSurfaceNode
retryActive?: boolean
/** Fork the session through the turn containing this message (user-bubble branch action). */
onFork?: (seq: number) => void
@@ -109,6 +117,24 @@ function ModelRetryItem({ node, active, t }: {
</details>
)
}
/** Persistent, turn-positioned feedback for a terminal failure. */
function TurnErrorItem({ node, t }: {
node: TurnErrorNode
t: ChatViewSlotProps['t']
}) {
return (
<div className={css.turnErrorRow} role="status">
<StateDot state="error" className={css.turnErrorDot} />
<div className={css.turnErrorCopy}>
<span className={css.turnErrorTitle}>{t('message.turnError')}</span>
<span className={css.turnErrorMessage}>{node.message}</span>
</div>
{node.code !== undefined && <code className={css.turnErrorCode}>{node.code}</code>}
</div>
)
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -141,42 +167,52 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
/** Right-aligned bubble shared by user and steering rows (steering has no actions). */
function UserStyleBubble({
content, actions, t,
}: {
content: readonly unknown[]
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, rest } = contentText(content)
const truncated = (total: number): string => t('json.truncated', { total })
return (
<div className={css.userRow}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
{actions?.(text)}
</div>
)
}
export const MessageItem = memo(function MessageItem({
node, retryActive = false, onFork, t,
}: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user': {
const { text, rest } = contentText(node.content)
case 'user':
return (
<div className={css.userRow}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
<MessageIconActions
text={text}
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
</div>
<UserStyleBubble
content={node.content}
t={t}
actions={text => (
<MessageIconActions
text={text}
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
)}
/>
)
}
case 'steering': {
const { text, rest } = contentText(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
<span className={css.badge}>{t('message.steering')}</span>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
</div>
</div>
)
}
case 'steering':
return <UserStyleBubble content={node.content} t={t} />
case 'context':
return (
<ContextInjectionRow content={node.content} source={node.source} t={t} />
@@ -185,6 +221,8 @@ export const MessageItem = memo(function MessageItem({
return <CompactionItem node={node} t={t} />
case 'model-retry':
return <ModelRetryItem node={node} active={retryActive} t={t} />
case 'turn-error':
return <TurnErrorItem node={node} t={t} />
default:
return (
<div className={css.contextRow}>

View File

@@ -29,7 +29,7 @@ export interface SessionInput extends InputTarget {
/** Single write path for draft text (all mutation rides machine events). */
setDraft(text: string): void
/** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
submit(mode?: 'queue' | 'steer'): void
submit(): void
/**
* Surface a notice outside the machine's own effect stream: detached
* command results and business notifications render through here.
@@ -60,7 +60,7 @@ export interface InputActions {
/** Single public draft write path (full next draft; occurrence math via diff scan). */
setDraft(text: string): void
/** Enter submission (adjudication / claim transaction / default sink inside). */
submit(mode?: 'queue' | 'steer'): void
submit(): void
}
/** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */
@@ -239,7 +239,7 @@ export type InputEvent =
| { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
/** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
| { readonly type: 'invalidate-paste' }
| { readonly type: 'enter'; readonly mode: 'queue' | 'steer' }
| { readonly type: 'enter' }
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
| { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
@@ -258,5 +258,5 @@ export type InputEvent =
export type InputEffect =
| { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
| { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
| { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' }
| { readonly type: 'default-sink'; readonly draft: string }
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }

View File

@@ -39,7 +39,7 @@ export interface SessionInputDeps {
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
defaultSink(text: string, mode: 'queue' | 'steer'): void
defaultSink(text: string): void
}
/** Guard tier from the machine phase. */
@@ -68,7 +68,7 @@ export class SessionInputShell implements SessionInput {
/** The public provide-channel action face (one stable identity per session — decision 20). */
readonly actions: InputActions = {
setDraft: (text) => { this.setDraft(text) },
submit: (mode) => { this.submit(mode) },
submit: () => { this.submit() },
}
// Real wall clock: the typing-run merge window must actually expire in
@@ -151,10 +151,9 @@ export class SessionInputShell implements SessionInput {
* from the machine; this method only feeds the event. Lock entry
* (adjudicating/submitting) force-closes the transient layers: the popup
* dismisses and the menu tracks frozen.
* @param mode - default-sink mode (queue appends; steer interrupts).
*/
submit(mode: 'queue' | 'steer' = 'queue'): void {
this.run(this.core.dispatch({ type: 'enter', mode }))
submit(): void {
this.run(this.core.dispatch({ type: 'enter' }))
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
this.deps.popup?.()?.dismiss()
@@ -339,7 +338,7 @@ export class SessionInputShell implements SessionInput {
return
}
case 'default-sink': {
this.sinkSerialized(fx.draft, fx.mode)
this.sinkSerialized(fx.draft)
return
}
default:
@@ -354,10 +353,10 @@ export class SessionInputShell implements SessionInput {
* send — notice + draft and chips retained, never a silent downgrade to
* the clipboard text. Chip-free drafts skip the async detour.
*/
private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void {
private sinkSerialized(draft: string): void {
const occurrences = this.core.state.occurrences
if (occurrences.length === 0) {
this.deps.defaultSink(draft.trim(), mode)
this.deps.defaultSink(draft.trim())
return
}
const slash = this.deps.slash?.()
@@ -377,7 +376,7 @@ export class SessionInputShell implements SessionInput {
cursor = part.offset + 1
}
out += draft.slice(cursor)
this.deps.defaultSink(out.trim(), mode)
this.deps.defaultSink(out.trim())
},
(error: unknown) => {
controller.abort()

View File

@@ -56,7 +56,7 @@ export class InputHub implements InputService {
slash: () => this.controller(actx),
popup: () => this.popup(actx),
queue: queueReadFaceOf(session),
defaultSink: (text, mode) => { this.sink(session, text, mode) },
defaultSink: (text) => { this.sink(session, text) },
})
this.shells.set(id, shell)
// The one teardown axis: listeners, shell, and map entries all ride the
@@ -123,12 +123,12 @@ export class InputHub implements InputService {
* exactly one path; a failed first prompt is an ordinary prompt failure
* (error strip via promptError, draft restored only while untouched).
*/
private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
private sink(session: SessionFace, text: string): void {
if (text === '') return
const shell = this.shells.get(session.sessionId)
// Commit, not an editable clear: undo must not resurrect sent content.
shell?.commitSend()
void session.prompt([{ type: 'text', text }], mode).then(
void session.prompt([{ type: 'text', text }], 'queue').then(
(result) => {
if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
},

View File

@@ -112,7 +112,6 @@ export class InputMachine {
private inflight: {
readonly attempt: SubmitAttempt
readonly controller: AbortController
readonly mode: 'queue' | 'steer'
} | undefined
private log: Transaction[] = []
private redoStack: Transaction[] = []
@@ -163,7 +162,7 @@ export class InputMachine {
this.paste = undefined
return []
}
case 'enter': return this.onEnter(ev.mode)
case 'enter': return this.onEnter()
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
case 'submit-settled': return this.onSubmitSettled(ev)
@@ -462,18 +461,18 @@ export class InputMachine {
// ---- submit plane ----
/** Mint the next SubmitAttempt and take the in-flight slot. */
private beginAttempt(mode: 'queue' | 'steer'): SubmitAttempt {
private beginAttempt(): SubmitAttempt {
const controller = new AbortController()
this.seq += 1
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft }
this.inflight = { attempt, controller, mode }
this.inflight = { attempt, controller }
return attempt
}
private onEnter(mode: 'queue' | 'steer'): InputEffect[] {
private onEnter(): InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
if (this.phase === 'claimed' && this.claim !== undefined) {
const attempt = this.beginAttempt(mode)
const attempt = this.beginAttempt()
this.phase = 'submitting'
this.paste = undefined
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
@@ -482,11 +481,11 @@ export class InputMachine {
if (trimmed === '') return []
this.paste = undefined
if (trimmed.startsWith('/')) {
const attempt = this.beginAttempt(mode)
const attempt = this.beginAttempt()
this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft: this.draft }]
}
return [{ type: 'default-sink', draft: this.draft, mode }]
return [{ type: 'default-sink', draft: this.draft }]
}
private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
@@ -507,7 +506,7 @@ export class InputMachine {
this.inflight = undefined
this.phase = 'plain'
return outcome === undefined
? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }]
? [{ type: 'default-sink', draft: attempt.draftSnapshot }]
: []
}

View File

@@ -47,7 +47,6 @@ export const zh = {
'chat.loadOlder': '加载更早',
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.steering': '插话',
'message.contextInjection': '上下文注入',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
@@ -63,6 +62,7 @@ export const zh = {
'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s',
'message.retry.delay': '重试延迟:',
'message.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败',
'command.running': '执行中…',
'command.failed': '命令失败',
'command.done': '已完成',
@@ -147,7 +147,6 @@ export const en = {
'chat.loadOlder': 'Load earlier',
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.steering': 'Interjection',
'message.contextInjection': 'Context injection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',
@@ -163,6 +162,7 @@ export const en = {
'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
'message.retry.delay': 'Retry delay: ',
'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed',
'command.running': 'Running…',
'command.failed': 'Command failed',
'command.done': 'Completed',

View File

@@ -25,12 +25,11 @@ export interface IConversation {
/** The per-session input machine registry (InputService face). */
readonly input: InputService
/**
* Send a prompt into the caller scope's session.
* Send a prompt into the caller scope's session (queued turn).
* @param text - prompt text, sent verbatim as one text block.
* @param mode - queue after the current turn, or steer into it.
* @returns completion; business failures reject (and land in promptError).
*/
send(text: string, mode: 'queue' | 'steer'): Promise<void>
send(text: string): Promise<void>
/**
* Apply one operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
@@ -71,11 +70,10 @@ export class ConversationService extends Service implements IConversation {
* session snapshot's promptError (object-layer surface); the rejection here
* exists for caller choreography (the composer restores the draft on it).
* @param text - prompt text, sent verbatim as one text block.
* @param mode - queue after the current turn, or steer into it.
*/
async send(text: string, mode: 'queue' | 'steer'): Promise<void> {
async send(text: string): Promise<void> {
const session = this.scopedSession('send')
const result = await session.prompt([{ type: 'text', text }], mode)
const result = await session.prompt([{ type: 'text', text }], 'queue')
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
}

View File

@@ -193,7 +193,7 @@ export function InputBar({
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (locked || machineBusy) return
inputActions.submit('queue')
inputActions.submit()
}
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
@@ -291,7 +291,7 @@ export function InputBar({
return
}
/* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
if (!empty && !disabled && !machineBusy) inputActions.submit()
}
// The Access seat: the projection-fed permission chip (renders nothing

View File

@@ -105,7 +105,7 @@ async function bench() {
}
const actions = info.props['inputActions'] as {
setDraft: (text: string) => void
submit: (mode?: 'queue' | 'steer') => void
submit: () => void
}
return { state, actions }
}
@@ -144,25 +144,25 @@ describe('conversation slot inject surface', () => {
const { state, actions } = b.inputSurface(ROOT)
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
actions.setDraft(' ')
actions.submit('queue')
actions.submit()
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
expect(state.getSnapshot().draft).toBe(' ')
// Success: cleared and stays cleared.
actions.setDraft('hello')
actions.submit('queue')
actions.submit()
expect(state.getSnapshot().draft).toBe('')
await Promise.resolve()
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
// Failure: restored (draft still empty when the rejection lands).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
actions.setDraft('retry me')
actions.submit('queue')
actions.submit()
await vi.waitFor(() => {
expect(state.getSnapshot().draft).toBe('retry me')
})
// Failure landing after new typing: no clobber (restore fills empty only).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
actions.submit('queue')
actions.submit()
actions.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
expect(state.getSnapshot().draft).toBe('typed during flight')

View File

@@ -102,7 +102,7 @@ describe('MessageItem arms', () => {
fireEvent.click(screen.getByRole('button', { name: '复制' }))
})
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
it('steering bubbles render text and non-text rest blocks, without user actions or a badge', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'steering', seq: 2, turn: 1, source: null,
@@ -110,7 +110,7 @@ describe('MessageItem arms', () => {
} as never}
/>,
)
expect(view.getByText('插话')).toBeTruthy()
expect(view.queryByText('插话')).toBeNull()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
expect(view.queryByRole('button', { name: '复制' })).toBeNull()

View File

@@ -8,7 +8,7 @@ import { Profiler } from 'react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import type {
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
UserMessageNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -75,6 +75,11 @@ const retry = (seq: number): ModelRetryNode => ({
retry: 1, maxRetries: 2, delayMs: 450,
failure: { code: 'TRANSPORT', message: '连接被重置' },
})
const turnError = (seq: number, code?: string): TurnErrorNode => ({
kind: 'turn-error', seq, time: seq * 1_000, turn: 1, step: 0,
message: seq === 2 ? 'API key is invalid' : 'plugin exploded',
...(code === undefined ? {} : { code }),
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
@@ -288,6 +293,16 @@ describe('ChatView', () => {
expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消')
})
it('renders terminal turn failures inline with their durable message and optional code', () => {
const h = makeHarness({ nodes: [user(1, 'try'), turnError(2, 'AUTH'), turnError(3)] })
const view = render(<h.ChatView {...h.props} />)
const statuses = view.getAllByRole('status')
expect(statuses.map(status => status.textContent)).toEqual([
'本轮运行失败API key is invalidAUTH',
'本轮运行失败plugin exploded',
])
})
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
const h = makeHarness({
nodes: [toolResult(3, 'a')],

View File

@@ -133,7 +133,7 @@ describe('Enter semantics', () => {
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('hello', 'queue')
expect(sink).toHaveBeenCalledWith('hello')
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
expect(sink).toHaveBeenCalledTimes(1)
const empty = bench({ draft: ' ' })
@@ -201,7 +201,7 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(textarea.disabled).toBe(false) // running no longer locks
fireEvent.change(textarea, { target: { value: '排队消息2' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
expect(sink).toHaveBeenCalledWith('排队消息2')
expect(button.getAttribute('aria-label')).toBe('停止生成')
fireEvent.click(button)
expect(stop).toHaveBeenCalledTimes(1)
@@ -217,7 +217,7 @@ describe('running and lock semantics (queue cut 1)', () => {
it('idle primary sends and disables on empty draft', () => {
const { button, sink } = bench({ draft: 'go' })
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('go', 'queue')
expect(sink).toHaveBeenCalledWith('go')
const empty = bench()
expect(empty.button.disabled).toBe(true)
})
@@ -357,7 +357,7 @@ describe('machine pending lock', () => {
},
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
shell.submit('queue')
shell.submit()
})
expect(shell.snapshot.phase).toBe('submitting')
const textarea = view.container.querySelector('textarea')!

View File

@@ -40,9 +40,9 @@ function effectAt<T extends InputEffect['type']>(
}
/** Drive plain → adjudicating and hand back the minted attempt. */
function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
function enterAdjudicating(m: InputMachine, draft: string): SubmitAttempt {
m.dispatch({ type: 'draft-changed', draft })
const fx = m.dispatch({ type: 'enter', mode })
const fx = m.dispatch({ type: 'enter' })
return effectAt(fx, 0, 'adjudicate').attempt
}
@@ -52,7 +52,7 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt
m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
m.dispatch({ type: 'draft-changed', draft: claim.token + args })
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
const fx = m.dispatch({ type: 'enter' })
return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
}
@@ -63,24 +63,24 @@ function staleAttempt(): SubmitAttempt {
describe('input-machine: plain × enter', () => {
it('empty and whitespace-only drafts produce nothing', () => {
const m = new InputMachine()
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.dispatch({ type: 'enter' })).toEqual([])
m.dispatch({ type: 'draft-changed', draft: ' \n ' })
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.dispatch({ type: 'enter' })).toEqual([])
expect(m.state.phase).toBe('plain')
})
it('non-command text falls to the default sink with the given mode', () => {
it('non-command text falls to the default sink', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'hello world' })
expect(m.dispatch({ type: 'enter', mode: 'steer' }))
.toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }])
expect(m.dispatch({ type: 'enter' }))
.toEqual([{ type: 'default-sink', draft: 'hello world' }])
expect(m.state.phase).toBe('plain')
})
it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
const fx = m.dispatch({ type: 'enter' })
const eff = effectAt(fx, 0, 'adjudicate')
expect(eff.draft).toBe('/goal x')
expect(eff.attempt.draftSnapshot).toBe('/goal x')
@@ -91,14 +91,14 @@ describe('input-machine: plain × enter', () => {
it('leading is judged after trim including newlines', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
expect(m.dispatch({ type: 'enter' })[0]?.type).toBe('adjudicate')
})
it('a non-whitespace prefix before "/" is not leading — default sink', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
expect(m.dispatch({ type: 'enter', mode: 'queue' }))
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }])
expect(m.dispatch({ type: 'enter' }))
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x' }])
})
})
@@ -124,11 +124,11 @@ describe('input-machine: adjudication outcomes', () => {
expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x')
})
it('undefined outcome falls back to the default sink preserving the enter mode', () => {
it('undefined outcome falls back to the default sink', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
const attempt = enterAdjudicating(m, '/unknown thing')
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
.toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }])
.toEqual([{ type: 'default-sink', draft: '/unknown thing' }])
expect(m.state.phase).toBe('plain')
})
@@ -152,7 +152,7 @@ describe('input-machine: adjudication outcomes', () => {
it('enter is a no-op while adjudicating (pending lock)', () => {
const m = new InputMachine()
enterAdjudicating(m, '/goal x')
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.dispatch({ type: 'enter' })).toEqual([])
expect(m.state.phase).toBe('adjudicating')
})
@@ -587,7 +587,7 @@ describe('input-machine: paste plane', () => {
const b = new InputMachine()
b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
b.dispatch({ type: 'enter', mode: 'queue' })
b.dispatch({ type: 'enter' })
expect(b.state.paste).toBeUndefined()
})
@@ -755,7 +755,7 @@ describe('input-machine: submitting transaction', () => {
it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
const m = new InputMachine()
enterSubmitting(m, 'goal', 'x')
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.dispatch({ type: 'enter' })).toEqual([])
expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
})
@@ -768,7 +768,7 @@ describe('input-machine: submitting transaction', () => {
m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'draft-changed', draft: '/goal go' })
const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
const attempt = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
@@ -809,7 +809,7 @@ describe('input-machine: submitting transaction', () => {
const m = new InputMachine()
const { attempt: first } = enterSubmitting(m, 'goal', 'x')
m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
const second = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
expect(second.seq).not.toBe(first.seq)
expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
expect(m.state.phase).toBe('submitting')

View File

@@ -87,7 +87,7 @@ describe('matrix row: plain', () => {
fireEvent.change(textarea, { target: { value: '普通消息' } })
expect(shell.snapshot.claim).toBeUndefined()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
expect(sink).toHaveBeenCalledWith('普通消息')
expect(shell.snapshot.phase).toBe('plain')
})
})
@@ -186,7 +186,7 @@ describe('matrix row: locked (session disabled)', () => {
expect((textarea).disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队', 'queue')
expect(sink).toHaveBeenCalledWith('排队')
})
})

View File

@@ -234,7 +234,7 @@ describe('scenario D: execute-kind /compact', () => {
act(() => { b2.shell.setDraft('/compact 现在') })
fireEvent.keyDown(b2.textarea, { key: 'Enter' })
// execute with trailing → matchEnter answers undefined → default sink.
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') })
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在') })
expect(b2.executed).toHaveLength(0)
})
})
@@ -288,7 +288,7 @@ describe('scenario I: unknown /xyz + enter', () => {
const b = await bench()
act(() => { b.shell.setDraft('/xyz 干点啥') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥') })
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.execute).not.toHaveBeenCalled()
})

View File

@@ -33,11 +33,11 @@ async function bench() {
describe('ConversationService', () => {
it('routes operations through the public Session binding', async () => {
const b = await bench()
await b.scoped.send('hello', 'steer')
await b.scoped.send('hello')
await b.scoped.updateQueue('item-1' as never, { kind: 'remove' })
await b.scoped.cancel()
await b.scoped.loadOlder()
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' })
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.loadOlder).toHaveBeenCalledOnce()
@@ -47,7 +47,7 @@ describe('ConversationService', () => {
it('folds Session business failures into callback rejections', async () => {
const b = await bench()
b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never)
await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy')
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
await b.runtime.dispose()
@@ -55,9 +55,9 @@ describe('ConversationService', () => {
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
const b = await bench()
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
await b.runtime.sessions.remove('s1')
await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/)
await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/)
await b.runtime.dispose()
// No SessionsService at all: a bare context (the runtime always provides one).
const bare = new Context()
@@ -65,6 +65,6 @@ describe('ConversationService', () => {
input: new InputHub(bare),
}).await()
const orphan = bare.get('conversation') as ConversationService
await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
})
})

View File

@@ -217,7 +217,7 @@ describe('ConversationRoot resident composer', () => {
fireEvent.change(box, { target: { value: 'ordinary revised' } })
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
expect(b.sink).toHaveBeenCalledWith('ordinary revised')
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
expect(b.open).toHaveBeenCalledWith(sid('root'))
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: 8ebc55d7ad202622bbbc50b3d91a6985a1be29b9
README.zh.md: d13a8941889654f05b8cafd3f6f4251a13e0b694
README.md: 2854c678fd93d56267d0aec8515455b77d4bcbf1
README.zh.md: 51db6cacfe83d176a6cc68b01fe3dd91acf49e25

View File

@@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar
The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write.

View File

@@ -6,7 +6,7 @@
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。
Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。

View File

@@ -13,9 +13,8 @@ import { useCallback, useEffect, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
WorkspaceCreateError,
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
import type {
WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
@@ -80,7 +79,6 @@ export function WorkspacePickFlow({
const [modalError, setModalError] = useState<string | null>(null)
const [flowOpen, setFlowOpen] = useState(false)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
// One picking interaction at a time: while the flow is open (native chooser
// pending, browse dialog up) or its pick is being adopted, every other
// menu action stays disabled — a late outcome must not race a concurrent
@@ -130,10 +128,6 @@ export function WorkspacePickFlow({
setFlowOpen(false)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
setFolderConflict(
reason instanceof WorkspaceCreateError
&& reason.rpcError.code === 'workspace-name-conflict',
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setFlowOpen(false)
setErrorOpen(true)
@@ -143,7 +137,6 @@ export function WorkspacePickFlow({
onClose()
setErrorOpen(false)
setModalError(null)
setFolderConflict(false)
setFlowOpen(true)
}, [onClose])
@@ -174,7 +167,6 @@ export function WorkspacePickFlow({
onCancel: () => { setFlowOpen(false) },
onError: (message) => {
setFlowOpen(false)
setFolderConflict(false)
setModalError(message)
setErrorOpen(true)
},
@@ -208,7 +200,7 @@ export function WorkspacePickFlow({
open={errorOpen}
onClose={closeModal}
closeLabel={t('close')}
title={folderConflict ? t('conflict.title') : t('folderError.title')}
title={t('folderError.title')}
footer={(
<>
<Button variant="outline" className={css.modalAction} onClick={closeModal}>{t('cancel')}</Button>
@@ -218,11 +210,7 @@ export function WorkspacePickFlow({
</>
)}
>
<div className={css.modalError} role="alert">
{folderConflict
? t('conflict.hint')
: modalError}
</div>
<div className={css.modalError} role="alert">{modalError}</div>
</Modal>
</>
)

View File

@@ -13,8 +13,8 @@
* picking interaction — a renderless native-chooser driver or an in-app
* browsing dialog. ui-workspace owns the trigger (the "Add workspace…"
* entry, present only while the hole is occupied) and the adoption
* semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose
* again); the occupant owns everything between `open` and the picked path,
* semantics (`createWorkspace({ path })`, the retryable error dialog,
* Choose again); the occupant owns everything between `open` and the picked path,
* including creating a new directory to hand back. That occupant-owned
* creation is why adding a workspace has a single route: an unoccupied hole
* leaves the surface with no add affordance at all.

View File

@@ -26,8 +26,6 @@ export const zh = {
'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。',
'menu.addWorkspace': '添加工作区…',
'picker.loading': '正在加载工作区…',
'conflict.title': '已存在同名工作区',
'conflict.hint': '请选择其他名称的文件夹。',
'conflict.named': '已存在名为“{name}”的工作区。',
'folderError.title': '无法打开文件夹',
'folderError.retry': '重新选择',
@@ -85,8 +83,6 @@ export const en = {
'search.hasMore': 'Showing the first {n} results. Narrow your search.',
'menu.addWorkspace': 'Add workspace…',
'picker.loading': 'Loading workspaces…',
'conflict.title': 'A workspace with this name already exists',
'conflict.hint': 'Choose a folder with a different name.',
'conflict.named': 'A workspace named “{name}” already exists.',
'folderError.title': 'Couldn’t open folder',
'folderError.retry': 'Choose again',

View File

@@ -725,7 +725,7 @@ describe('WorkspaceBrowser', () => {
await act(async () => { resolveDelete() })
// RPC success alone does not close: the component waits until its
// useWorkspaces projection has committed the removal, preventing a stale
// duplicate-name frame from leaking into the next create gesture.
// Workspace frame from leaking into the next gesture.
expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
rerender(browser, { useWorkspaces: hook(workspaceState([])) })
expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()

View File

@@ -4,7 +4,6 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import type {
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts'
@@ -115,10 +114,12 @@ function chooseAdd(): void {
}
describe('WorkspacePicker', () => {
it('lists real Workspaces from useWorkspaces and forwards a selected id', () => {
const b = mount()
fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
expect(b.onPick).toHaveBeenCalledWith(wid('alpha'))
it('lists same-title Workspaces separately and forwards the selected id', () => {
const b = mount([workspace('alpha', 'Shared'), workspace('beta', 'Shared')])
const entries = screen.getAllByRole('menuitem', { name: 'Shared' })
expect(entries).toHaveLength(2)
fireEvent.click(entries[1]!)
expect(b.onPick).toHaveBeenCalledWith(wid('beta'))
})
it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => {
@@ -156,26 +157,6 @@ describe('WorkspacePicker', () => {
expect(screen.queryByRole('dialog')).toBeNull()
})
it('shows a name conflict and retries by reopening the flow', async () => {
const createWorkspace = vi.fn(async () => {
throw new WorkspaceCreateError({
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
})
})
const b = mount([workspace('alpha', 'Alpha')], createWorkspace)
chooseAdd()
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '已存在同名工作区' })).toBeTruthy()
})
expect(screen.getByRole('alert').textContent).toBe('请选择其他名称的文件夹。')
// The failed adoption withdrew the flow; Choose again reopens it.
expect(b.probe.owner!.open).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '重新选择' }))
expect(b.probe.owner!.open).toBe(true)
expect(b.onPick).not.toHaveBeenCalled()
})
it('reports a non-Error adoption failure in the folder-error surface', async () => {
const b = mount([workspace('alpha', 'Alpha')], vi.fn(async () => { throw 'permission denied' }))
chooseAdd()
@@ -184,6 +165,9 @@ describe('WorkspacePicker', () => {
expect(screen.getByRole('dialog', { name: '无法打开文件夹' })).toBeTruthy()
})
expect(screen.getByRole('alert').textContent).toBe('permission denied')
expect(b.probe.owner!.open).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '重新选择' }))
expect(b.probe.owner!.open).toBe(true)
expect(b.onPick).not.toHaveBeenCalled()
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md
README.md: a166a46d20f472cb5d8f045e2456ce3e6de7a2f2
README.zh.md: 0575d549e352239e7d954870eaf40beea1169cc6
README.md: 053206797398aa952522298e82992a7320daf74c
README.zh.md: 439f3e7712b0803b07a9a7e9dd10d9e863876154

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md).
Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. The shipped TUI mounts it; `dsh-agent-spine-demo` and the Web/headless surfaces do not. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md).
## Config

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。`dsh-agent-spine-demo` 与随附示例均不挂载它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。
可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。已交付的 TUI 会挂载它;`dsh-agent-spine-demo` 与 Web/无头界面均不挂载。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。
## 配置

View File

@@ -1156,7 +1156,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'async create(path: string, title?: string): Promise<Workspace>',
jsDoc: '/**\n * Create or reuse a workspace for an existing directory. The path is\n * canonicalized through `fs.realpath`; a nonexistent path rejects with the\n * original error and a non-directory rejects. Repeated calls for the same\n * canonical path return the existing entity without changing its title.\n * A newly created workspace is prepended to the durable registry order.\n * A different canonical path cannot create a duplicate display title.\n * @param path - Existing directory to own, in any path spelling.\n * @param title - Display title used only when a new record is created.\n * @returns the existing or newly durable workspace.\n */',
jsDoc: '/**\n * Create or reuse a workspace for an existing directory. The path is\n * canonicalized through `fs.realpath`; a nonexistent path rejects with the\n * original error and a non-directory rejects. Repeated calls for the same\n * canonical path return the existing entity without changing its title.\n * A newly created workspace is prepended to the durable registry order.\n * Different canonical paths may share a display title.\n * @param path - Existing directory to own, in any path spelling.\n * @param title - Display title used only when a new record is created.\n * @returns the existing or newly durable workspace.\n */',
},
{
signature: 'get(id: WorkspaceId): Workspace | undefined',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 94698d02f599e10b1bbe69e2f9b1b058eb3e1647
README.zh.md: da63ff09d7ca52bdc10c8a8bce81be4c2a0abf0d
README.md: 5af2739ee419d32c93578b37bebc6808431fd003
README.zh.md: c2729c379bd3c132c94d96134c04a0976a3aca9b

View File

@@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the
Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events.
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.

View File

@@ -22,7 +22,7 @@
待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。

View File

@@ -21,7 +21,7 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
WorkspaceMoveInvalidError, WorkspaceNameConflictError, WorkspaceUnknownSessionError,
WorkspaceMoveInvalidError, WorkspaceUnknownSessionError,
} from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
@@ -522,6 +522,14 @@ class SessionCwdConflict extends Error {
/** Host failed before the registry could adopt a name-created directory. */
class WorkspaceDirectoryCreationError extends Error {}
/** An explicit Host naming operation would duplicate another Workspace title. */
class WorkspaceNameConflictError extends Error {
constructor(readonly workspaceName: string) {
super(`workspace name '${workspaceName}' is already in use`)
this.name = 'WorkspaceNameConflictError'
}
}
/** Shared workspace-not-found error response of the workspace.* mutation rows. */
function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> {
return err(request, {
@@ -570,7 +578,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const resumes = new Map<SessionId, Promise<Agent>>()
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
const sessionCreations = new Map<SessionId, Promise<Agent>>()
/** Serializes path ownership checks with record creation across spellings. */
/** Serializes path ownership and explicit title checks with Workspace mutations. */
let workspaceCreationChain = Promise.resolve()
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const pendingApprovals = new Map<RpcId, PendingApproval>()

View File

@@ -22,7 +22,7 @@ export interface WorkspaceView {
workspaceId: WorkspaceId
/** Canonical directory path (host-side realpath canon). */
path: string
/** Unique display title (defaults to the path basename at create). */
/** Display title (defaults to the path basename at create). */
title: string
/**
* Sessions accounted under this workspace, in manually owned order
@@ -53,8 +53,8 @@ export interface WorkspaceApi {
* root before registering. Either spelling resolving to a directory already
* owned by a workspace returns that workspace (`created: false`) for the
* existing-folder spelling. Create-by-name rejects an existing title with
* `workspace-name-conflict`; a new path whose basename duplicates another
* Workspace title is rejected by the registry with the same code.
* `workspace-name-conflict`; path adoption allows distinct canonical paths
* whose basenames produce the same display title.
* A new name-created workspace uses `name` as both directory name and title;
* a path-created workspace uses the registry's basename title default.
*/

View File

@@ -293,18 +293,25 @@ describe('workspace.create', () => {
}
})
it('rejects different paths that derive the same Workspace title', async () => {
it('adopts different paths that derive the same Workspace title', async () => {
const { api, workspaceRoot } = await harness()
const first = join(workspaceRoot, 'one', 'project')
const second = join(workspaceRoot, 'two', 'project')
mkdirSync(first, { recursive: true })
mkdirSync(second, { recursive: true })
expectOk(await api.workspace.create(request({ path: first })))
const conflict = await api.workspace.create(request({ path: second }))
expect(conflict.result).toMatchObject({
ok: false,
error: { code: 'workspace-name-conflict', details: { name: 'project' } },
const firstResult = expectOk(await api.workspace.create(request({ path: first })))
const secondResult = expectOk(await api.workspace.create(request({ path: second })))
expect(firstResult).toMatchObject({
created: true,
workspace: { path: first, title: 'project' },
})
expect(secondResult).toMatchObject({
created: true,
workspace: { path: second, title: 'project' },
})
expect(secondResult.workspace.workspaceId).not.toBe(firstResult.workspace.workspaceId)
expect(expectOk(await api.workspace.list(request({}))).items.map(workspace => workspace.path))
.toEqual([second, first])
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/tool-session-query/README.md
README.md: d973daf1124c4be05f7335b18661d431d45be39f
README.zh.md: b27d79a905a029d3750f24573e3c32785a314015
README.md: 9a70f29d7c39af816c9efcf479ad129f0148883c
README.zh.md: 55717aef20d53686cce963d09b2e41350d274a75

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; shipped host compositions do not mount it by default.
Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; the shipped TUI, Web, and headless compositions mount it by default, while ACP does not.
## Configuration

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已发布的宿主组合默认不挂载它。
位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已交付的 TUI、Web 与无头组合默认挂载它,而 ACP(Agent Client Protocol)不挂载。
## 配置

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e
README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d
README.md: c81cac891403e5294c4456ce4d4048ecd74666ce
README.zh.md: 01055619f4df460284564f0a1816de366d809e01

View File

@@ -10,7 +10,7 @@ Interactive terminals on macOS, Linux, and Windows are supported. Windows uses p
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
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, 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.
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.
@@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. During a live standalone compaction bracket, a fixed `Context being compacted <elapsed>` row appears above the prompt, the idle prompt caret becomes a one-cell throbbing `⊙`, and terminal progress stays active until close; the row and glyph share the bracket's one refresh timer. This live state is never reconstructed from the log; a failed close adds `Compaction failed: <error>` to the transcript, while a resumed orphaned start never activates the indicator ([decision](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md)). Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
@@ -79,9 +79,9 @@ Startup fails before mounting when either process stream is not a TTY. The compo
## Color
Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike — the startup banner's brand gradient is the one deliberate exception. Body text keeps the terminal's default foreground rather than a fixed shade.
Every general-purpose SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike. The startup banner gradient and the official mark's exact `#4D6BFE` ink are the two deliberate truecolor brand exceptions. Body text keeps the terminal's default foreground rather than a fixed shade.
There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, 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.
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.

View File

@@ -10,7 +10,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend
本包(package)只持有交互式终端展示和输入。它注入 `agents`、[`commands`](../commands/README.md)、`llm`、`systemPrompt`、`tokenMeter`、`tools` 和 `userInteraction`,可选读取 `skills` 服务(仅在已挂载时存在),然后驱动由 app 或开发者代码创建或恢复的 agent。Agent 生命周期、持久化与模型侧 [`ask_user_question`](../tool-ask-user/README.md) 工具仍是独立组合项。
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 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)持有该边界和未采用的替代方案。
终端成功启动后,本包会提供终端本地的 `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 消息——不渲染任何内容。
@@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。在实时独立压缩(compaction)标记对处于开启状态期间,提示词上方会显示固定的 `Context being compacted <elapsed>` 状态行,空闲提示符光标会变成占一个终端字符单元并呈呼吸律动的 `⊙`,终端进度状态则会保持活跃,直至标记对闭合;该状态行和字形共用标记对的同一个刷新定时器。该实时状态绝不会从日志中重建;闭合失败时会向 transcript 添加 `Compaction failed: <error>`,而恢复会话时遇到的陈旧未匹配 start 绝不会激活该指示器([决策](../../../.agents/notes/implemented/feature/2026-07-30-compaction-progress-visibility.md))。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
@@ -79,9 +79,9 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
## 颜色
TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读——启动 banner 的品牌渐变是唯一一个有意保留的例外。正文使用终端默认前景色,而非固定色调。
TUI 发出的所有通用 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读。启动 banner 渐变与官方标志使用的精确 `#4D6BFE` 色值是两处有意保留的真彩色品牌例外。正文使用终端默认前景色,而非固定色调。
每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。
每种视觉语义只对应一个角色:`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` 可移除所有样式。

View File

@@ -1,8 +1,8 @@
/**
* Per-step timing model and running-status glyph animation for the terminal
* Per-step timing model and prompt-status glyph animation for the terminal
* front door. Timing buckets are replayed from the session event stream; the
* running glyph fades in on turn start, throbs while the turn runs, and fades
* out on turn end.
* active glyph fades in when work starts, throbs while work runs, and fades out
* when it ends.
* @module @deepseek-ai/dsh-tui/chat/timing
*/
@@ -10,25 +10,25 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { Palette } from '../components/theme.ts'
/**
* Render cadence of the running prompt while active, and while the glyph fades
* out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly;
* Render cadence of the status prompt while active, and while the glyph fades
* out after work ends. ~20 fps so the truecolor glyph fade reads smoothly;
* the same tick keeps the elapsed-time text (0.1 s resolution) current. Only
* changed terminal cells are re-emitted, so the faster tick stays cheap.
*/
export const STATUS_ANIMATION_INTERVAL_MS = 50
/**
* Milliseconds over which the running glyph fades in when a turn starts and
* fades out after it ends. The fade is an envelope over the running pulse:
* Milliseconds over which the status glyph fades in when work starts and fades
* out after it ends. The fade is an envelope over the active pulse:
* inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}).
*/
export const STATUS_FADE_MS = 300
/** Milliseconds for one full brightness throb of the running glyph. */
/** Milliseconds for one full brightness throb of the active status glyph. */
export const STATUS_PULSE_PERIOD_MS = 1400
/**
* Brightness floor of the running throb, as a fraction of the settled gray. At
* Brightness floor of the status throb, as a fraction of the settled gray. At
* 0 the pulse swells from the near-background trough up to full and back. The
* trough is still rendered as the dimmest gray, not clipped to a blank, so the
* cosine breathes symmetrically bold→dim→bold.
@@ -36,7 +36,7 @@ export const STATUS_PULSE_PERIOD_MS = 1400
export const STATUS_PULSE_FLOOR = 0
/**
* Muted-gray foreground the truecolor running glyph fades through, from the
* Muted-gray foreground the truecolor status glyph fades through, from the
* near-background trough (opacity 0) to the settled dim gray (opacity 1). Same
* hue-free gray as the idle caret, so the glyph reads as the caret dimly
* appearing rather than a colored indicator. Foreground-only, matching the
@@ -185,6 +185,9 @@ export const TIMING_BUCKET_GLYPHS: Record<TimingBucket, string> = {
tools: '⚙',
}
/** Status glyph for a live standalone compaction bracket. */
const COMPACTING_GLYPH = '⊙'
/**
* Derive the currently open step's active timing bucket, or `undefined` when no
* step is open. The open step is the last `step/start` with no later matching
@@ -219,25 +222,32 @@ export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | u
}
/**
* The running agent's phase glyph, or `undefined` when idle. A running turn
* with no open step falls back to the pre-first-token wait so a glyph is always
* available while the agent works; it fades in on turn start, throbs while the
* turn runs, and fades out on turn end (see {@link fadeGlyph}).
* The active status glyph, or `undefined` when idle. A running turn takes
* precedence over standalone compaction and falls back to the pre-first-token
* wait when no step is open. The caller applies the shared fade and throb
* animation (see {@link fadeGlyph}).
* @param events - Session events to derive the phase from.
* @param running - Whether the agent is currently running.
* @returns The phase glyph, or `undefined` when idle.
* @param compacting - Whether a live standalone compaction bracket is open.
* @returns The active status glyph, or `undefined` when idle.
*/
export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined {
if (!running) return undefined
const bucket = openStepPhase(events) ?? 'ttft'
return TIMING_BUCKET_GLYPHS[bucket]
export function runningPhaseGlyph(
events: readonly SessionEvent[],
running: boolean,
compacting: boolean,
): string | undefined {
if (running) {
const bucket = openStepPhase(events) ?? 'ttft'
return TIMING_BUCKET_GLYPHS[bucket]
}
return compacting ? COMPACTING_GLYPH : undefined
}
/**
* The running throb's brightness at continuous clock `nowMs`: a cosine between
* The status throb's brightness at continuous clock `nowMs`: a cosine between
* {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the
* dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the
* fade envelope, which alone drives appear/disappear at turn boundaries.
* fade envelope, which alone drives appear/disappear at work boundaries.
*
* @param nowMs - Monotonic render clock in milliseconds.
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
@@ -249,14 +259,14 @@ export function pulseLevel(nowMs: number): number {
}
/**
* One frame of the running glyph at fade `opacity` (0 = near-background trough
* One frame of the status glyph at fade `opacity` (0 = near-background trough
* gray, 1 = settled dim gray). The character and its width never change — only
* the gray fades — so the prompt caret column stays fixed and the glyph reads as
* the caret dimly breathing, never a colored indicator.
*
* With truecolor the glyph's 24-bit gray foreground interpolates continuously
* between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade
* and the running throb render as a smooth, symmetric brightness swing with no
* and the status throb render as a smooth, symmetric brightness swing with no
* hard cutoff to clip the trough into a blank. Without truecolor there is no
* per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
* shows the glyph in the palette's muted role or leaves a blank column — a
@@ -264,7 +274,7 @@ export function pulseLevel(nowMs: number): number {
* no throb-driven blink. With color off entirely a visible glyph is bare,
* holding the caret column on a monochrome terminal.
*
* @param glyph - The phase glyph to paint.
* @param glyph - The status glyph to paint.
* @param palette - Active palette supplying the muted (dim gray) role.
* @param colorEnabled - Whether ANSI is emitted at all.
* @param truecolor - Whether the terminal accepts 24-bit foreground codes.

View File

@@ -46,6 +46,8 @@ export type AttributeRole = <T extends string>(text: T) => T
*/
export interface Palette {
accent: ColorRole
/** DeepSeek brand ink; exact gradient callers may override it on truecolor terminals. */
brand: ColorRole
/** The terminal's own default foreground; still a color, so it does not stack. */
text: ColorRole
/** The one recessed tone, below `text`: tool-card bodies, chrome, reasoning, footers. */
@@ -63,7 +65,7 @@ export interface Palette {
}
/** Names of the palette's color roles, in the order `/palette` prints them. */
export const COLOR_ROLES = ['text', 'dim', 'accent', 'code', 'success', 'warning', 'error'] as const
export const COLOR_ROLES = ['text', 'dim', 'accent', 'brand', 'code', 'success', 'warning', 'error'] as const
/** Names of the palette's attribute roles, in the order `/palette` prints them. */
export const ATTRIBUTE_ROLES = ['bold', 'italic', 'underline', 'strike', 'selected'] as const
@@ -86,8 +88,9 @@ export interface RoleSpec {
*
* Only the standard 16-color set and SGR attributes appear here. Terminals remap
* those to the user's active theme, so the TUI stays legible on any background;
* a fixed 24-bit color would not. The brand gradient is the one deliberate
* exception ({@link gradientText}).
* a fixed 24-bit color would not. The startup gradient and exact official mark
* color are the two deliberate brand exceptions ({@link gradientText},
* {@link brandText}).
*
* @param scheme - Active terminal color scheme; only `code` differs between them.
* @returns The SGR spec for every color and attribute role.
@@ -109,6 +112,7 @@ export function paletteSpec(scheme: TerminalColorScheme): {
// prominent text on screen.
dim: { open: '2;39', close: '22;39', purpose: 'The one recessed tone: tool bodies, chrome, footers' },
accent: { open: '95', close: '39', purpose: 'The one emphasis color: role headers, prompt, borders' },
brand: { open: '34', close: '39', purpose: 'DeepSeek brand art when truecolor is unavailable' },
// ANSI 36 (cyan) is difficult to read on a light background — use ANSI 34
// (blue) which is legible on both light and dark schemes.
code: scheme === 'light'
@@ -168,6 +172,19 @@ const BRAND_GRADIENT = [
[36, 152, 255], // #2498FF
] as const
/** Official DeepSeek icon ink from the shipped 24x24 SVG. */
const DEEPSEEK_BRAND_RGB = BRAND_GRADIENT[0]
/**
* Paint trusted static DeepSeek brand art with the official `#4D6BFE` ink.
* @param text - Static brand text or raster cells.
* @returns text wrapped in the official truecolor foreground and a foreground reset.
*/
export function brandText(text: string): string {
const [r, g, b] = DEEPSEEK_BRAND_RGB
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`
}
/**
* Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear
* interpolation across its stops.
@@ -199,13 +216,12 @@ function brandColorAt(t: number): readonly [number, number, number] {
* @returns `text` wrapped in truecolor SGR foreground codes.
*/
export function gradientText(text: string): string {
// The sole caller passes the ASCII product name, so UTF-16 unit iteration
// samples exactly one color per visible letter.
const last = Math.max(1, text.length - 1)
const glyphs = Array.from(text)
const last = Math.max(1, glyphs.length - 1)
let painted = ''
for (let index = 0; index < text.length; index += 1) {
for (let index = 0; index < glyphs.length; index += 1) {
const [r, g, b] = brandColorAt(index / last)
painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}`
painted += `\x1b[38;2;${r};${g};${b}m${glyphs[index]}`
}
return `${painted}\x1b[39m`
}

View File

@@ -37,6 +37,8 @@ export interface TuiFocusable {
export interface TuiTheme {
/** Render ordinary foreground text. */
readonly text: (value: string) => string
/** Render trusted static brand art with the host's configured brand treatment. */
readonly brand: (value: string) => string
/** Render secondary information and low-emphasis hints, the one tone below `text`. */
readonly dim: (value: string) => string
/** Render the active accent role. */

View File

@@ -69,7 +69,7 @@ import type {
TuiTheme,
} from './extension/types.ts'
import { displayInlineText, displayText } from './components/text.ts'
import { createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
import { brandText, createPalette, markdownTheme, renderPalette, selectTheme } from './components/theme.ts'
import { contentText, parseArguments } from './components/content.ts'
import {
cacheHitRate,
@@ -80,6 +80,7 @@ import {
import {
fadeGlyph,
formatQueuedStatus,
formatStatusDuration,
openStepPhase,
openTurn,
pulseLevel,
@@ -329,6 +330,7 @@ export function createTuiChat(
})
editor.hintPrefix = initialInputPrompt
const todo = new TodoComponent(palette)
const compactionStatusLine = new Text('', 0, 0)
let showReasoning = resolved.showReasoning
// Ctrl+O cycles collapsed -> expanded -> hidden. Codex-style: hidden drops
// tool cards entirely, collapsed previews, expanded shows full bodies.
@@ -337,6 +339,14 @@ export function createTuiChat(
let completedStreaming: StreamingAssistantComponent | undefined
let runningStatus: RunningStatus | undefined
let fadingStatus: FadingStatus | undefined
/**
* Live standalone compaction observed by this process. Never derive this
* state from history: a resumed log may contain a stale orphaned start.
*/
let compacting: {
startedAt: number
timer: ReturnType<typeof setInterval>
} | undefined
// TUI steering submissions that the inbox has not yet claimed or discarded.
// Correlation ids avoid guessing whether a running-state submission actually
// joined steering or fell back to the queued-turn FIFO during turn close.
@@ -400,6 +410,7 @@ export function createTuiChat(
throw new Error('TUI prompt built-ins failed to initialize')
}
const updatePromptValues = (): void => {
const renderTime = now()
cwdValue.set(palette.bold(palette.accent(formattedCwd)))
gitValue.set(branch === undefined ? undefined : palette.dim(` (${displayText(branch)})`))
const rate = cacheHitRate(tokens)
@@ -413,23 +424,31 @@ export function createTuiChat(
const queued = runningStatus === undefined ? undefined : formatQueuedStatus(pendingSteering.size)
queuedValue.set(queued === undefined ? undefined : palette.dim(queued))
symbolValue.set(palette.bold(palette.accent('dsh')))
compactionStatusLine.setText(compacting === undefined
? ''
: palette.dim(`Context being compacted ${formatStatusDuration(renderTime - compacting.startedAt)}`))
// `${indicator}` owns the caret column and its trailing gap before the
// cursor. The phase glyph replaces the `>` caret in place — same width
// every frame — fading in as a turn starts, throbbing while it runs, and
// fading out after it ends before the plain `>` returns. Only the gray
// cursor. The active status glyph replaces the `>` caret in place — same
// width every frame — fading in when work starts, throbbing while it runs,
// and fading out after it ends before the plain `>` returns. Only the gray
// brightness changes, so the cursor never shifts.
const runningGlyph = runningPhaseGlyph(agent.session.events, runningStatus !== undefined)
const statusGlyph = runningPhaseGlyph(
agent.session.events,
runningStatus !== undefined,
compacting !== undefined,
)
// Remember the live phase glyph so the fade-out shows it, not the ttft
// fallback the derivation returns once the closing turn's step has ended.
if (runningStatus !== undefined && runningGlyph !== undefined) runningStatus.lastGlyph = runningGlyph
// The fade envelope gates appear/disappear; the running throb breathes the
// glyph the whole turn. Truecolor opacity is envelope × throb; the
if (runningStatus !== undefined && statusGlyph !== undefined) runningStatus.lastGlyph = statusGlyph
// The fade envelope gates appear/disappear; the active throb breathes the
// glyph throughout the operation. Truecolor opacity is envelope × throb; the
// non-truecolor fallback keys visibility off the envelope alone, so the
// throb never blinks it. `envelope` clamps to [0, 1].
const envelope = runningStatus !== undefined && runningGlyph !== undefined
? { glyph: runningGlyph, level: Math.min(1, (now() - runningStatus.startedAt) / STATUS_FADE_MS) }
const activeSince = runningStatus?.startedAt ?? compacting?.startedAt
const envelope = activeSince !== undefined && statusGlyph !== undefined
? { glyph: statusGlyph, level: Math.min(1, (renderTime - activeSince) / STATUS_FADE_MS) }
: fadingStatus !== undefined
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (now() - fadingStatus.endedAt) / STATUS_FADE_MS) }
? { glyph: fadingStatus.glyph, level: Math.max(0, 1 - (renderTime - fadingStatus.endedAt) / STATUS_FADE_MS) }
: undefined
const caret = envelope === undefined
? palette.dim('>')
@@ -438,7 +457,7 @@ export function createTuiChat(
palette,
resolved.theme.color,
resolved.theme.color && resolved.theme.truecolor,
envelope.level * pulseLevel(now()),
envelope.level * pulseLevel(renderTime),
envelope.level >= 0.5,
)
indicatorValue.set(`${caret}${palette.dim(' ')}`)
@@ -453,6 +472,7 @@ export function createTuiChat(
ui.addChild(new Spacer(1))
todoContainer.addChild(todo)
ui.addChild(todoContainer)
ui.addChild(compactionStatusLine)
ui.addChild(promptContext)
ui.addChild(editor)
ui.setFocus(editor)
@@ -486,6 +506,9 @@ export function createTuiChat(
const extensionTheme: TuiTheme = Object.freeze({
text: (value: string) => palette.text(value),
brand: (value: string) => resolved.theme.color
? resolved.theme.truecolor ? brandText(value) : palette.brand(value)
: value,
dim: (value: string) => palette.dim(value),
accent: (value: string) => palette.accent(value),
success: (value: string) => palette.success(value),
@@ -537,8 +560,8 @@ export function createTuiChat(
requestRender()
}
/** Stop the running and fade-out timers and drop both states at once. */
const clearStatus = (): void => {
/** Stop the turn-phase running and fade-out timers and drop both states. */
const clearTurnStatus = (): void => {
if (runningStatus !== undefined) {
clearInterval(runningStatus.timer)
runningStatus = undefined
@@ -547,21 +570,30 @@ export function createTuiChat(
clearInterval(fadingStatus.timer)
fadingStatus = undefined
}
runtime.terminal.setProgress(false)
runtime.terminal.setProgress(compacting !== undefined)
}
/** Hard clear: drop every indicator, including a live compaction bracket. */
const clearStatus = (): void => {
if (compacting !== undefined) {
clearInterval(compacting.timer)
compacting = undefined
}
clearTurnStatus()
}
/**
* On the running → non-running edge, hand the last rendered glyph to a
* fade-out that re-renders until it settles on the `>` caret, then stops its
* own timer. A hard clear (teardown) skips this via {@link clearStatus}.
* Hand the last active glyph to a fade-out that re-renders until it settles
* on the `>` caret, then stops its own timer. A hard clear (teardown) skips
* this via {@link clearStatus}.
*/
const beginFadeOut = (glyph: string): void => {
clearStatus()
clearTurnStatus()
const fading: FadingStatus = {
glyph,
endedAt: now(),
timer: setInterval(() => {
if (now() - fading.endedAt >= STATUS_FADE_MS) clearStatus()
if (now() - fading.endedAt >= STATUS_FADE_MS) clearTurnStatus()
renderStatus()
}, STATUS_ANIMATION_INTERVAL_MS),
}
@@ -571,9 +603,9 @@ export function createTuiChat(
const setStatus = (status: AgentStatus): void => {
const priorTurn = runningStatus?.turn
const fadeOutGlyph = status !== 'running' ? runningStatus?.lastGlyph : undefined
if (status === 'running') clearStatus()
if (status === 'running') clearTurnStatus()
else if (fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
else clearStatus()
else clearTurnStatus()
editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
editor.hint = status === 'running' ? palette.dim(displayInlineText(resolved.theme.inputPlaceholder)) : undefined
if (status === 'running') {
@@ -1498,6 +1530,32 @@ export function createTuiChat(
recordEventUsage(tokens, event)
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined
// Track live standalone compaction state.
if (event.type === 'compact/start' && event.data.turn === null) {
if (compacting === undefined) {
const startedAt = now()
compacting = {
startedAt,
timer: setInterval(renderStatus, STATUS_ANIMATION_INTERVAL_MS),
}
runtime.terminal.setProgress(true)
}
requestRender()
return
}
if (event.type === 'compact/end' && event.data.turn === null && compacting !== undefined) {
const fadeOutGlyph = runningPhaseGlyph(agent.session.events, false, true)
clearInterval(compacting.timer)
compacting = undefined
if (event.data.error !== undefined) {
appendNotice(`Compaction failed: ${event.data.error}`, 'warning')
}
// A concurrently running turn owns the indicator. Keep its timer and
// progress bit instead of letting the compaction fade clear that state.
if (runningStatus === undefined && fadeOutGlyph !== undefined) beginFadeOut(fadeOutGlyph)
requestRender()
return
}
// A replacement mutates only the model surface, so the rendered transcript
// keeps what it already showed; a landed summary checkpoint adds its marker.
if (isReplacementSurfaceEvent(event)) {
@@ -1541,6 +1599,9 @@ export function createTuiChat(
// TUI stays mounted. Retained agents accept deliveries after detachment, so
// without this a later send would drive a zombie agent/session; mark
// disposed so dispatchMessage reports it instead.
// The hard clear also retires live compaction. A later compact/end is
// intentionally presentation-silent: this disposal notice owns the
// terminal outcome, and no animation may survive agent detachment.
clearStatus()
appendNotice(`Agent "${agent.id}" was disposed.`, 'warning')
disposed = true

View File

@@ -20,6 +20,7 @@ import {
const theme: TuiTheme = Object.freeze({
text: (value: string) => `text:${value}`,
brand: (value: string) => `brand:${value}`,
muted: (value: string) => `muted:${value}`,
dim: (value: string) => `dim:${value}`,
accent: (value: string) => `accent:${value}`,

View File

@@ -42,7 +42,7 @@ import {
type TuiRuntime,
} from '../src/index.ts'
import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts'
import { ATTRIBUTE_ROLES, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
import { ATTRIBUTE_ROLES, brandText, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts'
import {
appendAssistant,
appendUser,
@@ -138,6 +138,12 @@ async function tick(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 25))
}
function promptWidth(output: string): number {
const row = output.split('\n').find(line => line.includes('dsh'))
if (row === undefined) throw new Error('prompt row not rendered')
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
}
async function setup(options: TuiHarnessOptions = {}) {
const terminal = new FakeTerminal()
const exit = vi.fn()
@@ -1951,12 +1957,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
// `dsh <glyph> ` with the same visible width as the idle `dsh > `, so the
// cursor never shifts. Assert both the glyph slot and that constant width
// (color is off in this harness, so output carries no ANSI to strip).
const promptWidth = (): number => {
const row = result.terminal.output.split('\n').find(line => line.includes('dsh'))
if (row === undefined) throw new Error('prompt row not rendered')
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
}
// Each phase swaps only the glyph character in the same slot at equal width.
const phaseGlyph: [() => void, string][] = [
[() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'weighing' } }), 'dsh ✻ '],
@@ -1969,8 +1969,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
drive()
await tick()
expect(result.terminal.output).toContain(expected)
runningWidth ??= promptWidth()
expect(promptWidth()).toBe(runningWidth)
runningWidth ??= promptWidth(result.terminal.output)
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
}
// Idle begins a fade-out; once it settles (clock past the fade window) the
@@ -1987,12 +1987,189 @@ describe('pi-tui chat lifecycle and transcript', () => {
return rows.at(-1) ?? ''
}
expect(promptRow()).toContain('dsh > ')
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙]/u)
expect(promptWidth()).toBe(runningWidth)
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙⊙]/u)
expect(promptWidth(result.terminal.output)).toBe(runningWidth)
await dispose(result)
})
it('shows a live standalone compaction in the fixed status area', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
const idleWidth = promptWidth(result.terminal.output)
result.session.append('compact/start', { turn: null })
clock = 1_000
result.terminal.output = ''
await new Promise(resolve => setTimeout(resolve, 75))
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(result.terminal.output).toContain('Context being compacted 1.0s')
expect(promptWidth(result.terminal.output)).toBe(idleWidth)
expect(result.terminal.progress.at(-1)).toBe(true)
clock = 1_450
result.terminal.output = ''
await new Promise(resolve => setTimeout(resolve, 75))
expect(result.terminal.output).toContain('Context being compacted 1.4s')
await dispose(result)
})
it('ignores a numbered compaction bracket while the status line is idle', async () => {
const result = await setup({ now: () => 1_000 })
result.session.append('compact/start', { turn: 1 })
await tick()
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('fades a closed standalone compaction back to the plain caret', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
clock = 1_000
result.session.append('compact/start', { turn: null })
await tick()
result.session.append('compact/end', { turn: null })
await tick()
clock = 2_000
await new Promise(resolve => setTimeout(resolve, 120))
result.terminal.output = ''
result.terminal.resize(result.terminal.columns + 1)
await tick()
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
expect(result.terminal.output).not.toContain('Context being compacted')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('reports a failed standalone compaction when its live bracket closes', async () => {
const result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
result.session.append('compact/start', { turn: null })
result.terminal.output = ''
result.session.append('compact/end', { turn: null, error: 'summary failed' })
await tick()
expect(result.terminal.output).toContain('Compaction failed: summary failed')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('preserves live compaction progress across an idle status edge', async () => {
let clock = 0
const result = await setup({ omitInitialLifecycle: true, now: () => clock })
result.session.append('compact/start', { turn: null })
clock = 1_000
result.terminal.output = ''
result.ctx.emit('agent/status', result.agent, 'idle')
result.terminal.resize(result.terminal.columns + 1)
await tick()
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(true)
await dispose(result)
})
it('keeps a running turn phase glyph ahead of standalone compaction', async () => {
let clock = 0
const result = await setup({ status: 'running', now: () => clock })
clock = 1_000
result.terminal.output = ''
result.session.append('compact/start', { turn: null })
await tick()
expect(result.terminal.output).toContain('dsh ◍ ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
result.session.append('compact/end', { turn: null })
await tick()
result.terminal.output = ''
result.terminal.resize(result.terminal.columns + 1)
await tick()
expect(result.terminal.output).toContain('dsh ◍ ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(true)
await dispose(result)
})
it('treats duplicate live compaction starts as one owned bracket', async () => {
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
let result: Awaited<ReturnType<typeof setup>> | undefined
let didDispose = false
let clock = 0
try {
result = await setup({ omitInitialLifecycle: true, now: () => clock })
intervalSpy.mockClear()
clearIntervalSpy.mockClear()
result.session.append('compact/start', { turn: null })
clock = 1_000
result.session.append('compact/start', { turn: null })
await tick()
expect(intervalSpy).toHaveBeenCalledOnce()
expect(result.terminal.output).toContain('dsh ⊙ ')
expect(result.terminal.progress.at(-1)).toBe(true)
result.session.append('compact/end', { turn: null })
await tick()
expect(clearIntervalSpy).toHaveBeenCalledOnce()
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
didDispose = true
} finally {
if (result !== undefined && !didDispose) await dispose(result)
intervalSpy.mockRestore()
clearIntervalSpy.mockRestore()
}
})
it('does not show compaction progress for a resumed orphaned start', async () => {
const result = await setup({
omitInitialLifecycle: true,
now: () => 1_000,
beforeMount(session) {
session.append('compact/start', { turn: null })
},
})
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toContain('dsh ⊙ ')
expect(result.terminal.output).not.toContain('Context being compacted')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
})
it('releases the live compaction timer and progress bit on dispose', async () => {
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
let result: Awaited<ReturnType<typeof setup>> | undefined
let didDispose = false
try {
result = await setup({ omitInitialLifecycle: true, now: () => 1_000 })
intervalSpy.mockClear()
clearIntervalSpy.mockClear()
result.session.append('compact/start', { turn: null })
expect(intervalSpy).toHaveBeenCalledOnce()
await dispose(result)
didDispose = true
expect(clearIntervalSpy).toHaveBeenCalledOnce()
expect(result.terminal.progress.at(-1)).toBe(false)
} finally {
if (result !== undefined && !didDispose) await dispose(result)
intervalSpy.mockRestore()
clearIntervalSpy.mockRestore()
}
})
// Extract the running glyph's interpolated gray channel from a rendered frame.
const glyphGray = (frame: string): number => {
const m = /\x1b\[38;2;(\d+);(\d+);(\d+)m●/u.exec(frame)
@@ -2100,7 +2277,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
it('shows the plain prompt caret while idle', async () => {
const result = await setup({ now: () => 0 })
expect(result.terminal.output).toContain('dsh > ')
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙]/u)
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙⊙]/u)
await dispose(result)
})
@@ -5183,6 +5360,7 @@ describe('TUI extension service', () => {
host.theme.accent(`${label} plugin overlay`),
[
host.theme.text('text'),
host.theme.brand('brand'),
host.theme.dim('dim'),
host.theme.success('success'),
host.theme.warning('warning'),
@@ -5560,6 +5738,10 @@ describe('terminal mounting', () => {
await dispose(result)
})
it('uses the official DeepSeek SVG ink for truecolor brand art', () => {
expect(brandText('mark')).toBe('\x1b[38;2;77;107;254mmark\x1b[39m')
})
it('detects a light terminal color scheme and switches the scheme-dependent code role', async () => {
const result = await setup({ config: { theme: { color: true } } })
// `dim` is scheme-independent (SGR 2 over the default foreground), so the

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md
README.md: 11dc8172392e530ab4ea16f1b60473e5befb8089
README.zh.md: 5c6e3cefe31759df27b8008861505527648ce3c4
README.md: bfa044de50fc71dca95637487df50dc551d40e1b
README.zh.md: 1711e4d90c4cc62cf6e18a6868cd362a2c2e9458

View File

@@ -8,7 +8,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n
## Shape
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; a different path cannot create a duplicate title.
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; different paths may share a display title.
- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it.
- `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity.
- `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry.

View File

@@ -8,7 +8,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领
## 结构
- `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径不能创建重复标题。
- `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径可以共用显示标题。
- `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。
- `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。
- `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。

View File

@@ -38,17 +38,6 @@ export function WorkspaceId(id: string): WorkspaceId {
return id as WorkspaceId
}
/** A create request would give two Workspaces the same display name. */
export class WorkspaceNameConflictError extends Error {
/**
* @param workspaceName - Conflicting display name.
*/
constructor(readonly workspaceName: string) {
super(`workspace name '${workspaceName}' is already in use`)
this.name = 'WorkspaceNameConflictError'
}
}
/**
* An archiveSession request named a session neither live nor in session
* persistence — a definite miss only; storage faults propagate as themselves.
@@ -145,7 +134,7 @@ export class WorkspaceRegistry extends Service {
* original error and a non-directory rejects. Repeated calls for the same
* canonical path return the existing entity without changing its title.
* A newly created workspace is prepended to the durable registry order.
* A different canonical path cannot create a duplicate display title.
* Different canonical paths may share a display title.
* @param path - Existing directory to own, in any path spelling.
* @param title - Display title used only when a new record is created.
* @returns the existing or newly durable workspace.
@@ -259,10 +248,6 @@ export class WorkspaceRegistry extends Service {
}
const workspaceName = title ?? basename(canonical)
if ([...this.entities.values()].some(entity => entity.title === workspaceName)) {
throw new WorkspaceNameConflictError(workspaceName)
}
const table = this.requireTable()
const state = this.requireState()
const id = WorkspaceId(randomUUID())

View File

@@ -10,7 +10,7 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionHeader } from '@deepseek-ai/dsh-session'
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceNameConflictError } from '../src/index.ts'
import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError } from '../src/index.ts'
import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
const DOMAIN_VERSION = 2
@@ -377,17 +377,15 @@ describe('WorkspaceRegistry create and lookup', () => {
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
})
it('rejects a duplicate display name on a different canonical path', async () => {
it('allows a duplicate display name on a different canonical path', async () => {
const firstDir = await makeDir('named-first')
const secondDir = await makeDir('named-second')
const { registry } = await harness()
await registry.create(firstDir, 'Shared')
await expect(registry.create(secondDir, 'Shared')).rejects.toEqual(
expect.objectContaining<Partial<WorkspaceNameConflictError>>({
workspaceName: 'Shared',
}),
)
expect(registry.list()).toHaveLength(1)
const first = await registry.create(firstDir, 'Shared')
const second = await registry.create(secondDir, 'Shared')
expect(first.title).toBe('Shared')
expect(second.title).toBe('Shared')
expect(registry.list()).toEqual([second, first])
})
it('rejects nonexistent and non-directory paths without changing order', async () => {