From 18739de7c62b9cfa25c1fffd056e8c7c83e8960b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 16:28:17 +0800 Subject: [PATCH 01/38] feat(client): improve trajectory JSON string display --- .../ui-primitives/src/JsonTree.module.css | 133 +++++++-- .../client/ui-primitives/src/JsonTree.tsx | 259 ++++++++++-------- .../src/client/TrajectoryTable.tsx | 9 +- 3 files changed, 273 insertions(+), 128 deletions(-) diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index 33ec3f30d3..59ed4ba033 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -29,17 +29,17 @@ .container { box-sizing: border-box; - width: max-content; - min-width: 100%; + width: 100%; + min-width: 0; margin: 0; - padding: 6px 8px 8px; + padding: 6px 8px 8px 8px; white-space: pre; } .expandedTopLevel { box-sizing: border-box; - width: max-content; - min-width: 100%; + width: 100%; + min-width: 0; padding: 6px 8px 8px 14px; } @@ -66,11 +66,13 @@ .row { position: relative; box-sizing: border-box; - min-width: 100%; + min-width: 0; min-height: 16px; margin: 0; padding: 0 0 0 10px; list-style: none; + overflow-wrap: anywhere; + white-space: pre-wrap; } .row:not(.topLevelBracket):hover:not(:has(.row:hover))::after, @@ -81,13 +83,13 @@ top: 0; right: 0; left: 0; - height: 16px; + height: var(--json-tree-row-height, 16px); background: var(--json-tree-hover); content: ''; pointer-events: none; } -.row > span:not(.expander) { +.row > span:not(.expander, .copySlot) { position: relative; z-index: 1; } @@ -106,6 +108,74 @@ color: var(--json-tree-string); } +.stringField { + display: flow-root; +} + +.stringText { + display: block; + max-height: calc(var(--json-tree-collapsed-lines) * 16px); + padding-left: 12px; + overflow: hidden; + text-indent: -12px; + word-break: break-all; +} + +.stringField[data-expanded='true'] > .stringText { + max-height: none; + overflow: visible; +} + +.stringToggleSlot { + display: flex; + float: right; + align-items: flex-end; + height: calc(var(--json-tree-collapsed-lines) * 16px); + margin-left: 2px; + shape-outside: inset(calc((var(--json-tree-collapsed-lines) - 1) * 16px) 0 0); + pointer-events: none; +} + +.stringToggle { + display: inline-flex; + align-items: baseline; + gap: 2px; + margin: 0; + padding: 0; + border: 0; + color: var(--dsw-alias-label-secondary); + background: transparent; + font: inherit; + text-indent: 0; + white-space: nowrap; + cursor: pointer; + pointer-events: auto; +} + +.stringCollapse { + margin-left: 4px; +} + +.stringToggle:hover { + color: var(--dsw-alias-label-primary); +} + +.stringToggle:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 1px; +} + +.row:has(> .stringField) { + --json-tree-row-height: 100%; +} + +.summary { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .numberValue { color: var(--json-tree-number); } @@ -134,13 +204,42 @@ color: var(--dsw-alias-label-tertiary); } -.copyAnchor { - position: fixed; - z-index: 3; +.copySlot { display: inline-flex; + width: 20px; + height: 16px; + opacity: 0; + pointer-events: none; } -.copyButton { +.row > .copySlot { + position: absolute; + z-index: 2; + top: 0; + right: 0; +} + +.row:has(> .stringField) > .copySlot { + top: auto; + bottom: 0; +} + +.row:has(> .copySlot) > .stringField .stringToggleSlot { + padding-right: 24px; +} + +.row:has(> .copySlot) > .stringField .stringCollapse { + margin-right: 24px; +} + +.row:hover:not(:has(.row:hover)) > .copySlot, +.row[data-json-copy-active] > .copySlot, +.copySlot:focus-within { + opacity: 1; + pointer-events: auto; +} + +.actionButton { box-sizing: border-box; display: inline-flex; align-items: center; @@ -152,22 +251,20 @@ border: 0; border-radius: 3px; color: var(--dsw-alias-label-secondary); - background: var(--dsw-alias-bg-layer-1); - box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1); + background: transparent; cursor: pointer; } -.copyButton:hover { +.actionButton:hover { color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-hover); } -.copyButton:focus-visible { +.actionButton:focus-visible { outline: 1px solid var(--dsw-alias-state-business-primary); outline-offset: -1px; } -.copyButton[data-state='failed'] { +.actionButton[data-state='failed'] { color: var(--dsw-alias-state-error-primary); } diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 8399f3681e..064e8c323e 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -1,10 +1,10 @@ import clsx from 'clsx' -import { useEffect, useId, useRef, useState } from 'react' +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import type { + CSSProperties, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode, - UIEvent as ReactUIEvent, } from 'react' import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx' import { Menu } from './Menu.tsx' @@ -65,12 +65,6 @@ interface RowTarget { value: unknown } -interface CopyTarget extends RowTarget { - left: number - side: 'bottom' | 'top' - top: number -} - function isExpandableValue(value: unknown): value is object | unknown[] { return typeof value === 'object' && value !== null && !(value instanceof Date) } @@ -224,6 +218,7 @@ function NodeField({ } interface JsonTreeNodeProps { + collapsedStringLines: number field?: string initialExpanded: boolean labels: JsonTreeLabels @@ -231,11 +226,81 @@ interface JsonTreeNodeProps { onClaimTabStop: (id: string) => void onRowHover: (row: HTMLElement, target: RowTarget) => void path: JsonPath + renderCopy: ((path: JsonPath) => ReactNode) | undefined tabStopId: string | null value: unknown } +function JsonString({ + collapsedStringLines, + field, + labels, + lastElement, + value, +}: { + collapsedStringLines: number + field: string | undefined + labels: JsonTreeLabels + lastElement: boolean + value: string +}) { + const contentsId = useId() + const contentRef = useRef(null) + const [expanded, setExpanded] = useState(false) + const [truncated, setTruncated] = useState(false) + + useLayoutEffect(() => { + const content = contentRef.current as HTMLSpanElement + const measure = () => { + const lineHeight = Number.parseFloat(getComputedStyle(content).lineHeight) + setTruncated(content.scrollHeight > lineHeight * collapsedStringLines) + } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(content) + return () => { observer.disconnect() } + }, [collapsedStringLines, field, lastElement, value]) + + return ( + + + {truncated && !expanded && ( + + + + )} + {field !== undefined && {fieldText(field)}:} + {primitiveValue(value)} + {!lastElement && ,} + {truncated && expanded && ( + + )} + + + ) +} + function JsonTreeNode({ + collapsedStringLines, field, initialExpanded, labels, @@ -243,6 +308,7 @@ function JsonTreeNode({ onClaimTabStop, onRowHover, path, + renderCopy, tabStopId, value, }: JsonTreeNodeProps) { @@ -281,10 +347,23 @@ function JsonTreeNode({ onRowHover(event.currentTarget, { path, value }) }} > + {renderCopy?.(path)} {children} ) + if (typeof value === 'string') { + return row( + , + ) + } + if (!container) { return row(( <> @@ -322,14 +401,17 @@ function JsonTreeNode({ onClick={toggle} onKeyDown={onExpanderKeyDown} /> - - {previewValue(value, 0)} - {!lastElement && ,} + + + {previewValue(value, 0)} + {!lastElement && ,} + {expanded && (
    {entries.map(([key, item], index) => ( ))}
@@ -356,7 +439,7 @@ function formattedPath(path: JsonPath): string { }, '$') } -function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string { +function copyText(target: RowTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string { if (mode === 'path') return formattedPath(target.path) if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2) if (mode === 'json') return JSON.stringify(target.value) @@ -376,6 +459,8 @@ export interface JsonTreeProps { label: string /** Optional positioning class owned by the caller. */ className?: string | undefined + /** Maximum visible lines per collapsed string; defaults to 3. */ + collapsedStringLines?: number /** Whether JSON rows expose copy actions. */ copyable?: boolean /** Whether the top-level object or array is always expanded. */ @@ -393,6 +478,7 @@ export function JsonTree({ data, label, className, + collapsedStringLines = 3, copyable = true, expandTopLevel = true, labels, @@ -407,12 +493,11 @@ export function JsonTree({ ? null : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]]) : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null - const rootRef = useRef(null) const activeRowRef = useRef() const copyButtonRef = useRef(null) const copyMenuOpenRef = useRef(false) const resetTimer = useRef>() - const [copyTarget, setCopyTarget] = useState() + const [copyTarget, setCopyTarget] = useState() const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle') const [copyMenuOpen, setCopyMenuOpen] = useState(false) const [tabStopId, setTabStopId] = useState(initialTabStopId) @@ -431,33 +516,6 @@ export function JsonTree({ setCopyMenuOpen(false) } - const copyPosition = (row: HTMLElement): Pick => { - const root = rootRef.current - /* v8 ignore next -- row events and viewport listeners run only after the root ref mounts. */ - if (root === null) throw new Error('JsonTree root is not mounted') - const rootRect = root.getBoundingClientRect() - const rowRect = row.getBoundingClientRect() - return { - left: rootRect.left + root.clientWidth - 26, - side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom', - top: rowRect.top, - } - } - - const positionCopyButton = (row: HTMLElement, target: RowTarget) => { - const position = copyPosition(row) - setCopyTarget({ ...target, ...position }) - } - - const repositionCopyButton = (row: HTMLElement) => { - const position = copyPosition(row) - setCopyTarget((current) => { - /* v8 ignore next -- an active row and its copy target are installed together. */ - if (current === undefined) return current - return { ...current, ...position } - }) - } - useEffect(() => () => { if (resetTimer.current !== undefined) clearTimeout(resetTimer.current) activeRowRef.current?.removeAttribute('data-json-copy-active') @@ -473,19 +531,6 @@ export function JsonTree({ setTabStopId(initialTabStopId) }, [data, expandTopLevel, initialTabStopId]) - useEffect(() => { - const reposition = () => { - const row = activeRowRef.current - if (row !== undefined) repositionCopyButton(row) - } - window.addEventListener('scroll', reposition, true) - window.addEventListener('resize', reposition) - return () => { - window.removeEventListener('scroll', reposition, true) - window.removeEventListener('resize', reposition) - } - }, []) - const handleRowHover = (row: HTMLElement, target: RowTarget) => { if (!copyable || copyMenuOpenRef.current) return if (activeRowRef.current === row) return @@ -493,7 +538,7 @@ export function JsonTree({ setCopyState('idle') copyMenuOpenRef.current = false setCopyMenuOpen(false) - positionCopyButton(row, target) + setCopyTarget(target) } const handleRootMouseOver = (event: ReactMouseEvent) => { @@ -503,11 +548,6 @@ export function JsonTree({ if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget() } - const handleScroll = (_event: ReactUIEvent) => { - const row = activeRowRef.current - if (row !== undefined) repositionCopyButton(row) - } - const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => { /* v8 ignore next -- copy controls only render while their target exists. */ if (copyTarget === undefined) return @@ -530,15 +570,59 @@ export function JsonTree({ ? labels.copyFailed : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue + const renderCopy = copyable ? (path: JsonPath) => ( + + {copyTarget !== undefined && pathId(copyTarget.path) === pathId(path) && ( + void copy(defaultCopyMode)} + onContextMenu={(event) => { + event.preventDefault() + event.stopPropagation() + copyMenuOpenRef.current = true + setCopyMenuOpen(true) + }} + > + {copyState === 'copied' + ? + : } + + )} + items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} + onSelect={(id) => { + void copy(id as 'json' | 'path' | 'prettyJson' | 'value') + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + }} + onClose={clearCopyTarget} + getAnchorRect={() => ( + copyButtonRef.current as HTMLButtonElement + ).getBoundingClientRect()} + /> + )} + + ) : undefined + return (
{ if (!copyMenuOpenRef.current) clearCopyTarget() }} - onScroll={handleScroll} > {expandTopLevel ? ( @@ -551,6 +635,7 @@ export function JsonTree({ handleRowHover(event.currentTarget, { path: [], value: data }) }} > + {renderCopy?.([])} {rootOpen}
( ))}
@@ -581,6 +668,7 @@ export function JsonTree({ : (
)} - {copyTarget !== undefined && ( - - void copy(defaultCopyMode)} - onContextMenu={(event) => { - event.preventDefault() - event.stopPropagation() - copyMenuOpenRef.current = true - setCopyMenuOpen(true) - }} - > - {copyState === 'copied' - ? - : } - - )} - items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} - onSelect={(id) => { - void copy(id as 'json' | 'path' | 'prettyJson' | 'value') - copyMenuOpenRef.current = false - setCopyMenuOpen(false) - }} - onClose={clearCopyTarget} - getAnchorRect={() => ( - copyButtonRef.current as HTMLButtonElement - ).getBoundingClientRect()} - /> - - )} ) } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index d2a952704f..18417419b9 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -232,8 +232,8 @@ function jsonTreeLabels(t: TrajectoryTranslate): JsonTreeLabels { copyCompactJson: t('copy.compactJson'), copied: t('copied'), copyFailed: t('copy.failed'), - collapseNode: t('json.collapseNode'), - expandNode: t('json.expandNode'), + collapseNode: t('collapse'), + expandNode: t('expand'), copyButtonTitle: action => t('copy.optionsHint', { action }), } } @@ -856,6 +856,7 @@ function RequestOptions({ return ( {t('record.parameters')} Date: Wed, 9 Sep 2026 16:39:16 +0800 Subject: [PATCH 02/38] fix(trajectory): hide timing source in overview --- .../src/client/TrajectoryTable.tsx | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 18417419b9..b517578e80 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1546,14 +1546,20 @@ function MarkdownRecordContent({ ) } -function RecordTiming({ record, t }: { record: TableRecord; t: TrajectoryTranslate }) { +function RecordTiming({ + record, + preview = false, + t, +}: { record: TableRecord; preview?: boolean; t: TrajectoryTranslate }) { return record.cell.kind === 'message' && record.cell.assistantMetrics !== undefined ? : (
{t('timing.started')}
{t('timing.duration')}
{formatElapsedSeconds(record.cell.timeSeconds, t)}
-
{t('timing.source')}
{record.cell.timeSeconds === null ? t('timing.notAvailable') : t('timing.sessionTimestamps')}
+ {!preview && ( +
{t('timing.source')}
{record.cell.timeSeconds === null ? t('timing.notAvailable') : t('timing.sessionTimestamps')}
+ )}
) } @@ -1562,14 +1568,16 @@ function RequestTiming({ assistant, anchor, request, + preview = false, t, }: { assistant: TableRecord | undefined anchor: TableRecord | undefined request: TrajectoryRequestNumber | undefined + preview?: boolean t: TrajectoryTranslate }) { - if (assistant !== undefined) return + if (assistant !== undefined) return if (request?.startedAt !== undefined) { const duration = request.completedAt === null || request.completedAt === undefined ? null @@ -1578,10 +1586,12 @@ function RequestTiming({
{t('timing.started')}
{t('timing.duration')}
{formatElapsedSeconds(duration, t)}
-
-
{t('timing.source')}
-
{duration === null ? t('timing.sessionTimestampsRunning') : t('timing.sessionTimestamps')}
-
+ {!preview && ( +
+
{t('timing.source')}
+
{duration === null ? t('timing.sessionTimestampsRunning') : t('timing.sessionTimestamps')}
+
+ )}
) } @@ -2932,6 +2942,7 @@ export function TrajectoryTable({ assistant={selectedRequestAssistant} anchor={selectedRequestAnchor} request={selectedRequestInfo} + preview t={t} /> @@ -3157,12 +3168,12 @@ export function TrajectoryTable({ selectRequest(selectedAssistantRequestTarget, 'timing') }} > - + )} {(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && ( { activateTab('timing') }}> - + )} From e15a9b1beccdfdeee11eee72d5c489752cde7f2d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 9 Sep 2026 16:39:09 +0800 Subject: [PATCH 03/38] feat(web): add interactive sidebar terminals --- .../2026-09-09-web-sidebar-terminal.i18n.yaml | 6 + .../2026-09-09-web-sidebar-terminal.md | 45 ++ .../2026-09-09-web-sidebar-terminal.zh.md | 45 ++ THIRD_PARTY_NOTICES.md | 3 + .../sidebar-terminal/running.expected.md | 1 + .../tests/fixtures/sidebar-terminal.patch.yml | 6 + apps/web/tests/sidebar-right.e2e.ts | 28 +- apps/web/tests/sidebar-terminal.e2e.ts | 145 +++++ apps/web/tsconfig.json | 1 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 + docs/capability-seams.zh.md | 4 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 40 +- docs/config-catalog.zh.md | 38 ++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 4 + docs/module-graph.zh.md | 4 + docs/subsystems/subprocess.i18n.yaml | 4 +- docs/subsystems/subprocess.md | 11 +- docs/subsystems/subprocess.zh.md | 11 +- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 83 +++ docs/subsystems/workspace.zh.md | 83 +++ packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 1 + packages/api/README.zh.md | 1 + packages/api/remotes/package.json | 3 +- packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/tsconfig.client.json | 3 + packages/api/remotes/tsconfig.host.json | 3 + .../api/terminal-controller/README.i18n.yaml | 6 + packages/api/terminal-controller/README.md | 90 ++++ packages/api/terminal-controller/README.zh.md | 90 ++++ packages/api/terminal-controller/package.json | 92 ++++ .../src/client/close-requests.ts | 73 +++ .../terminal-controller/src/client/index.ts | 145 +++++ .../terminal-controller/src/client/model.ts | 284 ++++++++++ packages/api/terminal-controller/src/index.ts | 341 ++++++++++++ .../api/terminal-controller/src/shells.ts | 28 + .../api/terminal-controller/src/stream.ts | 75 +++ .../api/terminal-controller/src/terminal.ts | 169 ++++++ packages/api/terminal-controller/src/types.ts | 51 ++ .../tests/controller.spec.ts | 508 ++++++++++++++++++ .../tests/model.client.spec.ts | 503 +++++++++++++++++ .../tests/recovery.client.spec.ts | 369 +++++++++++++ .../terminal-controller/tests/stream.spec.ts | 80 +++ .../tests/terminal.spec.ts | 220 ++++++++ .../terminal-controller/tsconfig.client.json | 37 ++ .../terminal-controller/tsconfig.host.json | 53 ++ .../api/terminal-controller/tsconfig.json | 11 + .../api/terminal-controller/tsdown.config.ts | 7 + packages/bundle/web-app/cordis.patch.yml | 4 + packages/bundle/web-app/package.json | 4 +- packages/client/tsdown.client.ts | 3 +- .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 8 +- packages/client/ui-sidebar-right/README.zh.md | 8 +- .../src/client/contract/slots.ts | 2 +- .../ui-sidebar-right/src/client/index.ts | 8 +- .../ui-sidebar-right/src/client/service.ts | 65 ++- .../src/client/shell/SidebarRight.tsx | 15 +- .../ui-sidebar-right/src/client/tab-domain.ts | 4 +- .../src/client/tab-registry.ts | 2 + .../src/client/tabs/guide/GuideBody.tsx | 5 +- .../tests/apply.client.spec.ts | 4 +- .../tests/guide-body.client.spec.tsx | 8 +- .../tests/seat.client.spec.tsx | 29 +- .../tests/service.client.spec.ts | 159 +++++- .../tests/tab-domain.client.spec.ts | 2 +- .../ui-sidebar-terminal/README.i18n.yaml | 6 + packages/client/ui-sidebar-terminal/README.md | 80 +++ .../client/ui-sidebar-terminal/README.zh.md | 80 +++ .../client/ui-sidebar-terminal/package.json | 76 +++ .../src/client/TerminalBody.module.css | 9 + .../src/client/TerminalBody.tsx | 126 +++++ .../src/client/TerminalCleanup.module.css | 3 + .../src/client/TerminalCleanup.tsx | 29 + .../src/client/TerminalIcon.tsx | 9 + .../src/client/TerminalRecovery.tsx | 29 + .../src/client/TerminalTitle.tsx | 60 +++ .../ui-sidebar-terminal/src/client/face.ts | 19 + .../ui-sidebar-terminal/src/client/index.ts | 81 +++ .../ui-sidebar-terminal/src/client/locales.ts | 30 ++ .../ui-sidebar-terminal/src/css-modules.d.ts | 6 + .../client/ui-sidebar-terminal/src/index.ts | 4 + .../tests/apply.client.spec.ts | 175 ++++++ .../tests/cleanup.client.spec.tsx | 53 ++ .../tests/recovery.client.spec.tsx | 83 +++ .../tests/terminal-body.client.spec.tsx | 299 +++++++++++ .../client/ui-sidebar-terminal/tsconfig.json | 51 ++ .../ui-sidebar-terminal/tsdown.config.ts | 3 + packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 2 + packages/e2b/subprocess-e2b/README.zh.md | 2 + packages/e2b/subprocess-e2b/src/index.ts | 25 +- packages/e2b/subprocess-e2b/src/terminal.ts | 10 +- .../subprocess-e2b/tests/subprocess.spec.ts | 2 +- .../e2b/subprocess-e2b/tests/terminal.spec.ts | 57 +- .../src/client/slot-catalog.ts | 7 +- .../extensions/tool-cordis/src/api-catalog.ts | 96 +++- .../fs/tool-fs-search/tests/tools.spec.ts | 1 + .../shell/pwsh-local/tests/executor.spec.ts | 1 + .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 + .../subprocess/subprocess-local/README.zh.md | 2 + .../subprocess/subprocess-local/src/index.ts | 21 +- .../subprocess-local/src/terminal.ts | 22 +- .../tests/fixtures/process-exit-host.ts | 1 + .../tests/linux-scope.spec.ts | 1 + .../subprocess-local/tests/local.spec.ts | 59 +- .../tests/native-containment.spec.ts | 1 + .../subprocess-local/tests/terminal.spec.ts | 41 ++ .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 2 + packages/subprocess/subprocess/README.zh.md | 2 + packages/subprocess/subprocess/src/index.ts | 22 +- packages/subprocess/subprocess/src/types.ts | 16 + .../subprocess/tests/service.spec.ts | 2 + packages/terminal/terminal-bash/src/index.ts | 1 + .../terminal-bash/tests/index.spec.ts | 3 + .../terminal-bash/tests/session.spec.ts | 2 + pnpm-lock.yaml | 157 ++++++ scripts/gen-cordis-catalog.ts | 9 + scripts/gen-doc-graphs.ts | 7 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 5 + tsconfig.client.json | 2 + tsconfig.host.json | 2 + 132 files changed, 5985 insertions(+), 101 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md create mode 100644 .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md create mode 100644 apps/web/tests/expected/sidebar-terminal/running.expected.md create mode 100644 apps/web/tests/fixtures/sidebar-terminal.patch.yml create mode 100644 apps/web/tests/sidebar-terminal.e2e.ts create mode 100644 packages/api/terminal-controller/README.i18n.yaml create mode 100644 packages/api/terminal-controller/README.md create mode 100644 packages/api/terminal-controller/README.zh.md create mode 100644 packages/api/terminal-controller/package.json create mode 100644 packages/api/terminal-controller/src/client/close-requests.ts create mode 100644 packages/api/terminal-controller/src/client/index.ts create mode 100644 packages/api/terminal-controller/src/client/model.ts create mode 100644 packages/api/terminal-controller/src/index.ts create mode 100644 packages/api/terminal-controller/src/shells.ts create mode 100644 packages/api/terminal-controller/src/stream.ts create mode 100644 packages/api/terminal-controller/src/terminal.ts create mode 100644 packages/api/terminal-controller/src/types.ts create mode 100644 packages/api/terminal-controller/tests/controller.spec.ts create mode 100644 packages/api/terminal-controller/tests/model.client.spec.ts create mode 100644 packages/api/terminal-controller/tests/recovery.client.spec.ts create mode 100644 packages/api/terminal-controller/tests/stream.spec.ts create mode 100644 packages/api/terminal-controller/tests/terminal.spec.ts create mode 100644 packages/api/terminal-controller/tsconfig.client.json create mode 100644 packages/api/terminal-controller/tsconfig.host.json create mode 100644 packages/api/terminal-controller/tsconfig.json create mode 100644 packages/api/terminal-controller/tsdown.config.ts create mode 100644 packages/client/ui-sidebar-terminal/README.i18n.yaml create mode 100644 packages/client/ui-sidebar-terminal/README.md create mode 100644 packages/client/ui-sidebar-terminal/README.zh.md create mode 100644 packages/client/ui-sidebar-terminal/package.json create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx create mode 100644 packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx create mode 100644 packages/client/ui-sidebar-terminal/src/client/face.ts create mode 100644 packages/client/ui-sidebar-terminal/src/client/index.ts create mode 100644 packages/client/ui-sidebar-terminal/src/client/locales.ts create mode 100644 packages/client/ui-sidebar-terminal/src/css-modules.d.ts create mode 100644 packages/client/ui-sidebar-terminal/src/index.ts create mode 100644 packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts create mode 100644 packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx create mode 100644 packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx create mode 100644 packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx create mode 100644 packages/client/ui-sidebar-terminal/tsconfig.json create mode 100644 packages/client/ui-sidebar-terminal/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml new file mode 100644 index 0000000000..3da12183fa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +2026-09-09-web-sidebar-terminal.md: de51d0bb849de0599dfdaf86d2293c17618059cf +2026-09-09-web-sidebar-terminal.zh.md: e6aacebc424efc12457b4a31f0b2660a3ad6225b diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md new file mode 100644 index 0000000000..de51d0bb84 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -0,0 +1,45 @@ +# Agent Note: Web sidebar terminals + +Status: implemented + +English | [中文](2026-09-09-web-sidebar-terminal.zh.md) + +## Problem + +Web users need an interactive shell beside a Session to inspect the workspace and run commands. The Agent's persistent terminal tools control prompts and wait for semantic results; a human terminal instead needs raw keyboard input, normal shell configuration and a full screen. Browser rendering and transport can disappear while a command is still running. + +## Decision + +`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal starts the execution environment's default shell immediately. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs only during creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. + +Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. + +Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry. + +The caller retains a terminal ID in memory before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments. + +The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. + +## Alternatives considered + +**Add shell selection and terminal-specific creation controls.** Opening a terminal uses the execution environment's default shell, and the sidebar already owns tab creation and titles. Another picker, process list, header and add control would duplicate navigation around the terminal screen. + +**Keep the tab visible until process cleanup finishes.** A slow or failed termination would delay the user's close action. Saving the cleanup intent allows immediate removal while preserving failure reporting and retry. + +**Persist sidebar layout or an active tab-to-process registry.** Layout persistence is outside this feature. An additional active registry duplicates Host state and can restore stale associations. Only an unfinished close is an independent user request that must survive page reload. + +**Share the Agent terminal registry.** Its controlled prompts and semantic send/wait behavior would change human shell configuration and blur process ownership. User terminals share the subprocess capability instead. + +**Add a dedicated terminal WebSocket.** The existing Remote stream transport already owns authentication, cancellation and reconnection. A second carrier would duplicate those responsibilities. + +**Replay only a bounded raw byte tail.** A tail can begin inside an escape sequence or omit an alternate-screen transition. A serialized terminal screen provides a consistent recovery point with bounded history. + +**Kill when a React body or tab signal is disposed.** Unmount, Session switching and plugin reload can end those lifetimes without an explicit close. Cleanup must follow the user's close operation. + +**Build a Web completion engine.** Native shell completion already handles commands, paths and configured plugins through ordinary PTY input. An independent completion UI adds shell-specific parsing and synchronization; it is outside this feature. + +## Consequences + +A kept-open terminal retains a process and bounded screen memory. Reload restores Host-retained terminals rather than the previous sidebar layout; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. Changing sandbox mode requires closing retained terminals first. + +The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md new file mode 100644 index 0000000000..e6aacebc42 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -0,0 +1,45 @@ +# Agent Note: Web sidebar terminals + +Status: implemented + +[English](2026-09-09-web-sidebar-terminal.md) | 中文 + +## 问题 + +Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命令。Agent 的持久终端工具控制提示符并等待语义结果;人工终端需要原始键盘输入、正常 shell 配置和完整屏幕。命令仍在运行时,浏览器渲染和网络连接可能消失。 + +## 决定 + +`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端立即启动执行环境的默认 shell。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 只在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 + +关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 + +侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。 + +调用者在创建之前把终端 ID 保留在内存中。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。 + +最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 + +## 考虑过的替代方案 + +**增加 shell 选择和终端专用创建控件。** 打开终端使用执行环境的默认 shell,侧栏已经负责标签页创建与标题。额外的选择器、进程列表、标题栏和添加控件会在终端屏幕周围重复导航功能。 + +**进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。 + +**持久化侧栏布局或活跃标签页到进程的注册表。** 布局持久化不属于此功能。额外的活跃注册表重复 Host 状态,可能恢复过期关联。只有未完成的关闭操作是需要跨页面刷新保留的独立用户请求。 + +**共用 Agent 终端注册表。** 受控提示符和语义化 send/wait 会改变人工 shell 配置并混淆进程所有权。用户终端只共享 subprocess 能力。 + +**增加专用终端 WebSocket。** 现有 Remote stream 已管理认证、取消和重连。第二条传输通道会重复这些职责。 + +**只重放有界原始字节尾部。** 字节尾部可能从转义序列中间开始,或缺失备用屏幕切换。序列化终端屏幕能在保留有界历史的同时提供一致恢复点。 + +**React 正文或 tab signal 卸载时 kill。** 组件卸载、Session 切换和插件重载都可能结束这些生命周期,而用户并未关闭终端。清理必须跟随显式关闭操作。 + +**自建 Web 补全引擎。** shell 原生补全通过普通 PTY 输入处理命令、路径和已配置插件。独立补全界面需要针对 shell 解析和同步,不属于此功能。 + +## 影响 + +保留终端会保留进程和有界屏幕内存。刷新恢复的是 Host 保留的终端,不是此前的侧栏布局;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。改变 sandbox mode 前需要关闭保留的终端。 + +Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 31eebdab8d..7bde8e075f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -56,7 +56,10 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | +| [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT | +| [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT | | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | +| [`@xterm/xterm`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | | [`acorn`](https://github.com/acornjs/acorn) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | diff --git a/apps/web/tests/expected/sidebar-terminal/running.expected.md b/apps/web/tests/expected/sidebar-terminal/running.expected.md new file mode 100644 index 0000000000..91f8d58a99 --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/running.expected.md @@ -0,0 +1 @@ +- textbox "Terminal" diff --git a/apps/web/tests/fixtures/sidebar-terminal.patch.yml b/apps/web/tests/fixtures/sidebar-terminal.patch.yml new file mode 100644 index 0000000000..0c055161f1 --- /dev/null +++ b/apps/web/tests/fixtures/sidebar-terminal.patch.yml @@ -0,0 +1,6 @@ +- id: terminal-controller + config: + shell: + path: /bin/bash + name: bash + args: [--noprofile, --norc, -i] diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index c6a7ec07cd..603ce4076f 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -132,6 +132,7 @@ async function resetSidebar(page: Page): Promise { const column = page.locator('[data-rightbar-col]') await expandOf(page).waitFor({ timeout: 15_000 }) await ensureExpanded(page, column) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) await width(column) return column @@ -390,6 +391,10 @@ describe('web e2e: shipped right Sidebar', () => { expect(await centreY(selector), selector).toBe(textLine) } + await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + // A manual guide is closable beside Files and suppresses another add // control in its pane until it is closed. const addTab = column.locator('[data-dockkit-add-tab]') @@ -752,10 +757,13 @@ describe('web e2e: shipped right Sidebar', () => { ) await expect.poll(async () => await panes.count()).toBe(2) - const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) - expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(1) - await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5)) + const splitGuide = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + expect(await splitGuide.locator('[data-dockkit-tab-close]').count()).toBe(1) + await dragTo(page, splitGuide, await pointIn(panes.first(), 0.5, 0.5)) await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) + const movedGuide = panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + await movedGuide.hover() + await movedGuide.locator('[data-dockkit-tab-close]').click() // Neither pane holds a guide, so both offer an add control. const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) }) @@ -892,7 +900,7 @@ describe('web e2e: shipped right Sidebar', () => { expect(await panes.count()).toBe(2) expect(await splitButtons.count()).toBe(0) - // 5. A manual guide and the document float while Files stays docked. + // 5. The split's guide and the document float while Files stays docked. const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) await floatByDrag(page, floatOne) await expect.poll(async () => await floats.count()).toBe(1) @@ -901,8 +909,7 @@ describe('web e2e: shipped right Sidebar', () => { await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 }) await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x) - await panes.last().locator('[data-dockkit-add-tab]').click() - await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Files', 'Start']) + await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Start']) const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) await floatByDrag(page, second) await expect.poll(async () => await floats.count()).toBe(2) @@ -957,7 +964,7 @@ describe('web e2e: shipped right Sidebar', () => { // Any other tab standing alone closes together with the column. Open the // sample file, close the guide (an ordinary close with two tabs), then // close the file: the column collapses in the same gesture, and the - // settle rule reseeds the current default, so reopening shows Files. + // settle rule reseeds the current default, so reopening shows Start. await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click() await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME]) await column.locator('[data-dockkit-tab]').first().hover() @@ -967,8 +974,8 @@ describe('web e2e: shipped right Sidebar', () => { await column.locator('[data-dockkit-tab-close]').first().click() await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0) await expandOf(page).click() - await expect.poll(async () => await tabTitles(column)).toEqual(['Files']) - expect(await column.locator('[data-files-state="tree"]').count()).toBe(1) + await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) + expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -994,6 +1001,7 @@ describe('web e2e: shipped right Sidebar', () => { await ensureExpanded(page, column) await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-dockkit-add-tab]').click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start']) // No "more" control on the chip: the chip carries its close, and the menu @@ -1041,6 +1049,8 @@ describe('web e2e: shipped right Sidebar', () => { const column = zhPage.locator('[data-rightbar-col]') await expandOf(zhPage).waitFor({ timeout: 20_000 }) await expandOf(zhPage).click() + await expect.poll(async () => await tabTitles(column)).toEqual(['开始']) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await expect.poll(async () => await tabTitles(column)).toEqual(['文件']) await column.locator('[data-dockkit-add-tab]').click() diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts new file mode 100644 index 0000000000..ed416301b6 --- /dev/null +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -0,0 +1,145 @@ +/** Shipped sidebar terminal over the real Loader, Remote mux, Chromium and local PTY. */ +import { mkdir } from 'node:fs/promises' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-api-terminal-controller' +import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const expected = fileURLToPath(new URL('./expected/sidebar-terminal/running.expected.md', import.meta.url)) +const shots = fileURLToPath(new URL('../../../.artifacts/screenshots/sidebar-terminal/', import.meta.url)) + +async function openTerminal(page: Page): Promise { + const expand = page.locator('[data-sidebar-right-expand]') + if (await expand.isVisible()) await expand.click() + else await page.locator('[data-dockkit-add-tab]').click() + await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() + await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-') +} + +async function command(page: Page, text: string): Promise { + await page.locator('.xterm-helper-textarea:visible').click() + await page.keyboard.insertText(text) + await page.keyboard.press('Enter') +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0) + // A container's init may retain a reparented zombie; it cannot run after terminal cleanup. + if (process.platform === 'linux') { + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + const state = stat.slice(stat.lastIndexOf(')') + 2, stat.lastIndexOf(')') + 3) + return state !== 'Z' && state !== 'X' + } + return true + } catch (error) { + if (['ESRCH', 'ENOENT'].includes((error as NodeJS.ErrnoException).code ?? '')) return false + throw error + } +} + +describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('Workspace did not create a Session') + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Open a terminal.' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { stream: [], turn: 1, step: 1, message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Ready for terminal input.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn: 1, step: 1 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByText('Ready for terminal input.').waitFor() + await mkdir(shots, { recursive: true }) + }, 180_000) + + afterAll(async () => { await browser?.close(); await scaffold?.close() }) + + it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => { + onTestFailed(() => saveFailureShot(page, 'sidebar-terminal')) + await openTerminal(page) + const terminal = page.locator('[data-sidebar-terminal]') + await command(page, "PS1=''; printf '\\033cTERMINAL_READY\\n'") + const screen = page.locator('.xterm-rows:visible') + await expect.poll(async () => await screen.innerText()).toContain('TERMINAL_READY') + const aria = await terminal.ariaSnapshot() + await compareOrRefreshGolden(expected, aria, webSnapshotMode()) + await command(page, "printf 'DSH_PID:%s\\n' \"$$\"") + await expect.poll(async () => await screen.innerText()).toMatch(/DSH_PID:\d+/u) + const pid = Number((await screen.innerText()).match(/DSH_PID:(\d+)/u)?.[1]) + expect(alive(pid)).toBe(true) + await command(page, 'dsh_terminal_completion_probe(){ printf "completed_from_shell\\n"; }') + await page.keyboard.press('Control+l') + await page.keyboard.insertText('dsh_terminal_completion_pro') + await page.keyboard.press('Tab') + await page.keyboard.press('Enter') + await expect.poll(async () => await screen.innerText()).toContain('completed_from_shell') + await command(page, "printf 'PERSIST:%s\\n' \"$TERM\"") + await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') + await page.locator('[data-dockkit-tab-title]').getByText('bash', { exact: true }).dblclick() + await page.getByRole('textbox', { name: 'Terminal name', exact: true }).fill('Development') + await page.getByRole('textbox', { name: 'Terminal name', exact: true }).press('Enter') + await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toContain('Development') + await openTerminal(page) + await command(page, "printf 'SECOND_PID:%s\\n' \"$$\"") + await expect.poll(async () => await screen.innerText()).toMatch(/SECOND_PID:\d+/u) + const secondPid = Number((await screen.innerText()).match(/SECOND_PID:(\d+)/u)?.[1]) + expect(secondPid).not.toBe(pid) + await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).click() + await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') + expect(alive(secondPid)).toBe(true) + await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() + expect(alive(pid)).toBe(true) + await page.locator('[data-sidebar-right-expand]').click() + const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!) + const dockedCols = terminals()[0]!.cols + await page.getByRole('button', { name: 'Fullscreen', exact: true }).click() + await expect.poll(() => terminals()[0]!.cols).toBeGreaterThan(dockedCols) + await command(page, "printf 'SIZE:'; stty size") + await expect.poll(async () => await screen.innerText()).toContain(`SIZE:${terminals()[0]!.rows} ${terminals()[0]!.cols}`) + await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true }) + await page.reload({ waitUntil: 'load' }) + await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 }) + await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Start', 'Development', 'bash']) + expect(terminals()).toHaveLength(2) + expect(alive(pid)).toBe(true) + expect(alive(secondPid)).toBe(true) + await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`) + const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }) + await secondTab.hover() + await secondTab.locator('[data-dockkit-tab-close]').click() + await expect.poll(async () => await secondTab.count()).toBe(0) + await expect.poll(() => alive(secondPid), { timeout: 10_000 }).toBe(false) + await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') + await command(page, "printf 'RECOVERED_PID:%s\\n' \"$$\"") + await expect.poll(async () => await screen.innerText()).toContain(`RECOVERED_PID:${pid}`) + await page.screenshot({ path: `${shots}/recovered.png`, fullPage: true }) + await command(page, "sleep 120 & printf 'CHILD_PID:%s\\n' $!") + await expect.poll(async () => await screen.innerText()).toMatch(/CHILD_PID:\d+/u) + const childPid = Number((await screen.innerText()).match(/CHILD_PID:(\d+)/u)?.[1]) + expect(alive(childPid)).toBe(true) + const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }) + await tab.hover() + await tab.locator('[data-dockkit-tab-close]').click() + await expect.poll(() => alive(pid), { timeout: 10_000 }).toBe(false) + await expect.poll(() => alive(childPid), { timeout: 10_000 }).toBe(false) + await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!).length).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3b01e3990c..78b182d9a6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -94,6 +94,7 @@ "tests/feedback-release.e2e.ts", "tests/agent-team-panel.e2e.ts", "tests/sidebar-right.e2e.ts", + "tests/sidebar-terminal.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", "tests/produced-file-mentions.e2e.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index ab602f097d..a377e4ca6b 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 9cc7a6fc50b81e9f82d58600c84d1ff6494634c9 -capability-seams.zh.md: e898bdf3c2c728a1bae427bbb737eb5269a1c500 +capability-seams.md: 826b8b546242b6581846b52a3a5919c333610d42 +capability-seams.zh.md: 106f4b75180322820604a2f254f34dc8aa57abdf diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9cc7a6fc50..826b8b5462 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -46,6 +46,8 @@ flowchart LR svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] pkg_api_workspace_files["api-workspace-files"] svc_workspaceFiles["ctx.workspaceFiles
Host workspace file Remote service"] + pkg_api_terminal_controller["api-terminal-controller"] + svc_terminalController["ctx.terminalController
Session interactive terminal Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] @@ -234,6 +236,7 @@ flowchart LR pkg_api_session_controller --> svc_sessionSkillCatalog pkg_api_settings_controller --> svc_credentialsController pkg_api_settings_controller --> svc_settingsController + pkg_api_terminal_controller --> svc_terminalController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_api_workspace_files --> svc_workspaceFiles @@ -487,6 +490,7 @@ flowchart LR | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. | +| `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index e898bdf3c2..106f4b7518 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -48,6 +48,8 @@ flowchart LR svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] pkg_api_workspace_files["api-workspace-files"] svc_workspaceFiles["ctx.workspaceFiles
Host workspace file Remote service"] + pkg_api_terminal_controller["api-terminal-controller"] + svc_terminalController["ctx.terminalController
Session interactive terminal Remote controller"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking Remote controller"] @@ -236,6 +238,7 @@ flowchart LR pkg_api_session_controller --> svc_sessionSkillCatalog pkg_api_settings_controller --> svc_credentialsController pkg_api_settings_controller --> svc_settingsController + pkg_api_terminal_controller --> svc_terminalController pkg_api_workspace_controller --> svc_directoryPickerController pkg_api_workspace_controller --> svc_workspaceController pkg_api_workspace_files --> svc_workspaceFiles @@ -489,6 +492,7 @@ flowchart LR | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 | +| `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | 通过子进程提供方与类型化 Remote 传输管理用户终端进程、解析默认 shell,并恢复有界终端屏幕。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2f4b0e6e79..cc4bab3bc9 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 4e92bbfcf4bcf190b9b17436eefc3255dda9132a -config-catalog.zh.md: df4b107f28040962f38802a77f8b91c47ad2931d +config-catalog.md: bba3732438bc323b034625185962c5bce2382f3c +config-catalog.zh.md: 8c6168c46035e703a7c54e121dd20514e2ddf009 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4e92bbfcf4..bba3732438 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -229,6 +229,43 @@ export interface Config { Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-terminal-controller` + +Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert` + +```ts config-catalog +/** Deployment limits and an optional shell profile. */ +export interface Config { + /** Explicit shell profile; omission uses the execution environment's default shell. */ + readonly shell?: { + /** Executable path or PATH name, verified by the subprocess provider. */ + path: string + /** User-visible profile name. */ + name: string + /** Arguments passed to the interactive shell. */ + args: string[] + } | undefined + /** Maximum retained terminals and pending allocations per Session. */ + readonly maxTerminals: number + /** Maximum terminal width in columns. */ + readonly maxCols: number + /** Maximum terminal height in rows. */ + readonly maxRows: number + /** Screen history rows retained for reconnecting clients. */ + readonly scrollback: number + /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + readonly maxBufferedBytes: number + /** Maximum UTF-8 bytes in one input request. */ + readonly maxInputBytes: number + /** Provider process-termination grace period in milliseconds. */ + readonly disposeGraceMs: number +} +``` + +Source: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -2575,7 +2612,7 @@ export interface Config { } ``` -Source: [`packages/e2b/subprocess-e2b/src/index.ts:26`](../packages/e2b/subprocess-e2b/src/index.ts) +Source: [`packages/e2b/subprocess-e2b/src/index.ts:28`](../packages/e2b/subprocess-e2b/src/index.ts) @@ -3484,6 +3521,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-terminal` ([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index df4b107f28..8c6168c460 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -231,6 +231,43 @@ export interface Config { 来源:[`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-terminal-controller` + +Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert` + +```ts config-catalog +/** Deployment limits and an optional shell profile. */ +export interface Config { + /** Explicit shell profile; omission uses the execution environment's default shell. */ + readonly shell?: { + /** Executable path or PATH name, verified by the subprocess provider. */ + path: string + /** User-visible profile name. */ + name: string + /** Arguments passed to the interactive shell. */ + args: string[] + } | undefined + /** Maximum retained terminals and pending allocations per Session. */ + readonly maxTerminals: number + /** Maximum terminal width in columns. */ + readonly maxCols: number + /** Maximum terminal height in rows. */ + readonly maxRows: number + /** Screen history rows retained for reconnecting clients. */ + readonly scrollback: number + /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + readonly maxBufferedBytes: number + /** Maximum UTF-8 bytes in one input request. */ + readonly maxInputBytes: number + /** Provider process-termination grace period in milliseconds. */ + readonly disposeGraceMs: number +} +``` + +来源: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -3486,6 +3523,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-sidebar-terminal`([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 09eecc6d42..ad3f023604 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 2239c9786f9e18b6a8e9514bfd53da7f87cfb8a4 -event-producer-consumer.zh.md: e43d48e2ee163843752bcda9fe3578839e05252b +event-producer-consumer.md: 3114d32853689ea267a923490a1a667a0a54b1f0 +event-producer-consumer.zh.md: 42915ab51d194d4827bf047135d39a9f3198a353 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2239c9786f..3114d32853 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e43d48e2ee..42915ab51d 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -82,7 +82,7 @@ | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), `terminal-controller`, [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index e6eaa903f2..8e198a04a4 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: cbaa8cb04a7ec2406b1f6e7f2c1dbf0a8dff0527 -module-graph.zh.md: 148b2730fccc1740d1c2f24d766f2aa7213093cf +module-graph.md: 4e0db7406fa7b18bfef88e4a4828578aebcca327 +module-graph.zh.md: 590a3cb3de2fbd85a52bc8fabe5921905f148186 diff --git a/docs/module-graph.md b/docs/module-graph.md index cbaa8cb04a..4e0db7406f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -115,6 +115,7 @@ flowchart TD pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] + pkg_api_terminal_controller["api-terminal-controller"] pkg_api_workspace_controller["api-workspace-controller"] pkg_api_workspace_files["api-workspace-files"] end @@ -176,6 +177,7 @@ flowchart TD pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] pkg_client_ui_sidebar_files["client-ui-sidebar-files"] pkg_client_ui_sidebar_right["client-ui-sidebar-right"] + pkg_client_ui_sidebar_terminal["client-ui-sidebar-terminal"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] @@ -1198,6 +1200,7 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1244,6 +1247,7 @@ flowchart TD | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | +| [`client-ui-sidebar-terminal`](../packages/client/ui-sidebar-terminal) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 148b2730fc..590a3cb3de 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -117,6 +117,7 @@ flowchart TD pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] + pkg_api_terminal_controller["api-terminal-controller"] pkg_api_workspace_controller["api-workspace-controller"] pkg_api_workspace_files["api-workspace-files"] end @@ -178,6 +179,7 @@ flowchart TD pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] pkg_client_ui_sidebar_files["client-ui-sidebar-files"] pkg_client_ui_sidebar_right["client-ui-sidebar-right"] + pkg_client_ui_sidebar_terminal["client-ui-sidebar-terminal"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] @@ -1200,6 +1202,7 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1246,6 +1249,7 @@ flowchart TD | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — | | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — | +| [`client-ui-sidebar-terminal`](../packages/client/ui-sidebar-terminal) | `client` | — | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — | diff --git a/docs/subsystems/subprocess.i18n.yaml b/docs/subsystems/subprocess.i18n.yaml index 90672cfb7f..e816893e36 100644 --- a/docs/subsystems/subprocess.i18n.yaml +++ b/docs/subsystems/subprocess.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subprocess.md -subprocess.md: 782623760e70648a108ac51252b610e27bffbe45 -subprocess.zh.md: 7b8e590a843899b5f00a309a913e1dcb152c5c1b +subprocess.md: 71040fb28b8cf919f72f8c727a7d0c7add30dada +subprocess.zh.md: dda7356108305ac7e2f3182608b248ce6fa781de diff --git a/docs/subsystems/subprocess.md b/docs/subsystems/subprocess.md index 782623760e..71040fb28b 100644 --- a/docs/subsystems/subprocess.md +++ b/docs/subsystems/subprocess.md @@ -240,7 +240,9 @@ interface SubprocessOutcome { `spawnTerminal(spec)` is the non-pipe process primitive. The provider allocates the controlling terminal and owns UTF-8 text transport, foreground-process-group inspection and signalling, and one awaited TERM-to-KILL operation that reaches quiescence for every session member the provider can still observe; providers document substrate-specific observability limits. The PTY backend remains responsible for prompt detection, readiness inference, scrollback, sandbox policy, and persistent-session ownership; ordinary `spawn()` cannot reconstruct controlling-terminal semantics. -The terminal spec fully specifies argv, cwd, environment overrides, dimensions, cleanup grace, and optional allocation cancellation. Its handle exposes `pid`, ordered output, `done`, `write`, `inspectForeground`, `signalForeground`, and awaited `terminate`; the exact public shapes are generated into the [`ctx.subprocess` service catalog](#ctxsubprocess--subprocessruntime-abstract-seam). +The terminal spec fully specifies argv, cwd, environment overrides, terminal type, dimensions, cleanup grace, and optional allocation cancellation. Its handle exposes `pid`, ordered output, `done`, `write`, `resize`, `inspectForeground`, `signalForeground`, and awaited `terminate`; [`SubprocessTerminalSpawnSpec` and `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) define these fields and operations. `resize(cols, rows)` updates the live PTY dimensions and rejects after process exit. + +`terminalEnvironment(signal?)` returns `SubprocessTerminalEnvironment`: the execution environment platform (`posix` or `windows`) and optional `defaultShell`. These facts come from the provider rather than the Web server or browser. `resolveExecutable` verifies shell candidates; `SubprocessExecutableNotFoundError` identifies a missing executable, while provider and transport failures remain errors. ## Service behavior @@ -300,6 +302,13 @@ Implementations must honor these semantics: */ abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise +/** + * Inspect shell-selection facts in the provider's execution environment. + * @param signal - cancellation of remote environment inspection. + * @returns platform and preferred shell; executable lookup and allocation remain separate operations. + */ +abstract terminalEnvironment(signal?: AbortSignal): Promise + /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. diff --git a/docs/subsystems/subprocess.zh.md b/docs/subsystems/subprocess.zh.md index 7b8e590a84..dda7356108 100644 --- a/docs/subsystems/subprocess.zh.md +++ b/docs/subsystems/subprocess.zh.md @@ -240,7 +240,9 @@ interface SubprocessOutcome { `spawnTerminal(spec)` 是非管道进程原语。提供方分配控制终端,并负责 UTF-8 文本传输、前台进程组检查与信号发送,以及一项须等待的 TERM→KILL 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,提供方则会记录执行基底特有的可观察性限制。PTY 后端仍负责提示符检测、就绪推断、scrollback、沙箱策略和持久会话所有权;普通 `spawn()` 无法重建控制终端语义。 -终端 spec 完全指定 argv、cwd、环境覆盖、尺寸、清理宽限期与可选的分配取消。其句柄公开 `pid`、有序输出、`done`、`write`、`inspectForeground`、`signalForeground` 和须等待的 `terminate`;确切的公共形状生成到 [`ctx.subprocess` 服务目录](#ctxsubprocess--subprocessruntime-abstract-seam)中。 +终端 spec 完全指定 argv、cwd、环境覆盖、终端类型、尺寸、清理宽限期与可选的分配取消。其句柄公开 `pid`、有序输出、`done`、`write`、`resize`、`inspectForeground`、`signalForeground` 和须等待的 `terminate`;[`SubprocessTerminalSpawnSpec` 与 `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) 定义这些字段和操作。`resize(cols, rows)` 更新正在运行的 PTY 尺寸,进程退出后拒绝调用。 + +`terminalEnvironment(signal?)` 返回 `SubprocessTerminalEnvironment`:执行环境平台(`posix` 或 `windows`)与可选的 `defaultShell`。这些事实来自提供方,而非 Web 服务器或浏览器。`resolveExecutable` 验证候选 shell;`SubprocessExecutableNotFoundError` 表示可执行文件不存在,提供方与传输故障仍作为错误报告。 ## 服务行为 @@ -300,6 +302,13 @@ Implementations must honor these semantics: */ abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise +/** + * Inspect shell-selection facts in the provider's execution environment. + * @param signal - cancellation of remote environment inspection. + * @returns platform and preferred shell; executable lookup and allocation remain separate operations. + */ +abstract terminalEnvironment(signal?: AbortSignal): Promise + /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 154640d962..cb54a2833a 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: a7c4b8843f0a0b2d2fb251157f7928a40df1e405 -workspace.zh.md: 3474cbf147848b04a4ec51147983a343bcc1e91d +workspace.md: f37af82d7f8cec018d24a22534bc0b7342425d34 +workspace.zh.md: 02dd976abde63992b65f09ea4852cedf0d0b4b36 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index a7c4b8843f..f37af82d7f 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -183,6 +183,89 @@ Host service backing the generated `ctx.remote.directoryPicker` namespace. The s Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + + +### `ctx.terminalController` — `TerminalController` + +Typed Remote control of transient Session-owned terminal processes. + +```ts cordis-catalog +/** + * Read the Session working directory and terminal limits without resolving a shell. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns the Session workspace directory and terminal limits. + */ +@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment + +/** + * List running and exited terminals available for reattachment. + * @param agent - Session owner supplied by the Gateway. + * @returns terminals retained for this Host lifetime. + */ +@Remote list(agent: Agent): WebTerminalInfo[] + +/** + * Allocate an interactive shell once for a caller-generated identity. + * @param agent - Session owner supplied by the Gateway. + * @param request - initial dimensions and idempotency identity. + * @param signal - allocation cancellation; committed terminals survive disconnection. + * @returns the existing or newly committed terminal. + */ +@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise + +/** + * Attach to a terminal without binding its process lifetime to the transport. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - new exclusive input attachment. + * @param signal - physical stream cancellation. + * @returns screen recovery followed by output and metadata changes. + */ +@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable + +/** + * Deliver raw input, including Tab completion and control characters. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param data - input bytes represented as UTF-8 text. + * @returns after provider input acceptance. + */ +@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise + +/** + * Update the dimensions of the PTY and recovery screen. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param cols - column count. + * @param rows - row count. + * @returns after the resize completes. + */ +@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise + +/** + * Rename a terminal without changing its shell. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param title - nonempty display title, at most 120 characters. + */ +@Remote rename(agent: Agent, id: WebTerminalId, title: string): void + +/** + * Close an identity to future creation and kill its process range; repeated closes succeed. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @returns after provider cleanup succeeds. A failure retains the terminal for retry. + */ +@Remote async close(agent: Agent, id: WebTerminalId): Promise +``` + +Types: [Agent](core.md) + +Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 3474cbf147..02dd976abd 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -183,6 +183,89 @@ Host service backing the generated `ctx.remote.directoryPicker` namespace. The s Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts) + + +### `ctx.terminalController` — `TerminalController` + +Typed Remote control of transient Session-owned terminal processes. + +```ts cordis-catalog +/** + * Read the Session working directory and terminal limits without resolving a shell. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns the Session workspace directory and terminal limits. + */ +@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment + +/** + * List running and exited terminals available for reattachment. + * @param agent - Session owner supplied by the Gateway. + * @returns terminals retained for this Host lifetime. + */ +@Remote list(agent: Agent): WebTerminalInfo[] + +/** + * Allocate an interactive shell once for a caller-generated identity. + * @param agent - Session owner supplied by the Gateway. + * @param request - initial dimensions and idempotency identity. + * @param signal - allocation cancellation; committed terminals survive disconnection. + * @returns the existing or newly committed terminal. + */ +@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise + +/** + * Attach to a terminal without binding its process lifetime to the transport. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - new exclusive input attachment. + * @param signal - physical stream cancellation. + * @returns screen recovery followed by output and metadata changes. + */ +@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable + +/** + * Deliver raw input, including Tab completion and control characters. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param data - input bytes represented as UTF-8 text. + * @returns after provider input acceptance. + */ +@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise + +/** + * Update the dimensions of the PTY and recovery screen. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param cols - column count. + * @param rows - row count. + * @returns after the resize completes. + */ +@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise + +/** + * Rename a terminal without changing its shell. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param title - nonempty display title, at most 120 characters. + */ +@Remote rename(agent: Agent, id: WebTerminalId, title: string): void + +/** + * Close an identity to future creation and kill its process range; repeated closes succeed. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @returns after provider cleanup succeeds. A failure retains the terminal for retry. + */ +@Remote async close(agent: Agent, id: WebTerminalId): Promise +``` + +Types: [Agent](core.zh.md) + +Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) + ### `ctx.workspaceController` — `WorkspaceController` diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index ce98751e3d..290b5a8eb2 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/README.md -README.md: 20455e6b5622ffd6e826b1d4b427838f96f6610f -README.zh.md: 0294b1823e6a09cae4591b4c6081a546d8d60494 +README.md: a60e47948ffcb54e07b33ff63a1ea92e3c05998e +README.zh.md: 9e374c90c808f4314ba6565038bdba6bcca80cf9 diff --git a/packages/api/README.md b/packages/api/README.md index 20455e6b56..a60e47948f 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -31,6 +31,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | +| [`terminal-controller/`](terminal-controller/README.md) | Session-owned interactive shells, screen recovery and browser terminal control. | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the agent-write `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 0294b1823e..9e374c90c8 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -31,6 +31,7 @@ kind: "package-group" | [`session-controller/`](session-controller/README.zh.md) | 拥有 Session 命令、历史 stream、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | +| [`terminal-controller/`](terminal-controller/README.zh.md) | Session 拥有的交互式 shell、屏幕恢复和浏览器终端控制。 | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与 agent 写入的 `changes` 流——以及其上的 Client `file` 资源提供者。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0d7280d631..6f64346ae5 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -85,6 +85,7 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "zod": "^4.4.3", - "@deepseek-ai/dsh-command-feedback": "workspace:^" + "@deepseek-ai/dsh-command-feedback": "workspace:^", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:^" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index fa0dae245e..250a1efc5d 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -15,6 +15,7 @@ import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' +import terminalRemote from '@deepseek-ai/dsh-api-terminal-controller/remote' import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -38,6 +39,8 @@ export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-files/remote' export type * from '@deepseek-ai/dsh-api-workspace-files/types' +export type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +export type * from '@deepseek-ai/dsh-api-terminal-controller/types' export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. @@ -153,7 +156,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, - subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, + subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 7222a73c98..16f3789b71 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -82,6 +82,9 @@ }, { "path": "../../feedback/command-feedback" + }, + { + "path": "../terminal-controller/tsconfig.client.json" } ] } diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 99a3e5cc75..e17db7a665 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -67,6 +67,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../terminal-controller/tsconfig.host.json" } ] } diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml new file mode 100644 index 0000000000..2b60551cd2 --- /dev/null +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md +README.md: f6755cab8def6bc1f5842ee387a20c5ed51e7e7f +README.zh.md: fef6e6b45cef87e0a3f1c433dec13baad59ab75a diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md new file mode 100644 index 0000000000..f6755cab8d --- /dev/null +++ b/packages/api/terminal-controller/README.md @@ -0,0 +1,90 @@ +--- +description: "Interactive user terminals with execution-environment shell defaults, bounded screen recovery and typed Remote control." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-terminal-controller + +English | [中文](README.zh.md) + +## Summary + +Open the execution environment's default shell in a Session workspace from the Web sidebar. Reconnect to existing processes and close their complete provider-owned process ranges. Terminal output stays outside the Agent transcript. Keeping a terminal open retains its process and a bounded screen buffer. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. + +New terminals use the execution environment's declared default shell. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The provider verifies the executable during creation; resolution failure is reported without trying another shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal. + +| Configuration | Default | Meaning | +|---|---|---| +| `shell` | omitted | Use the execution environment's default shell, or one explicit profile | +| `maxTerminals` | `8` | Retained terminals and pending allocations per Session | +| `maxCols`, `maxRows` | `500`, `200` | Maximum PTY dimensions | +| `scrollback` | `1000` | Retained screen history rows | +| `maxBufferedBytes` | `2097152` | Output queued for one follower | +| `maxInputBytes` | `65536` | Maximum input request bytes | +| `disposeGraceMs` | `1000` | Provider termination grace in milliseconds | + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. + +The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. + +Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. + +Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup; startup retries saved requests. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload. + +
+ + +## Further Exploration + +- [Subprocess](../../subprocess/subprocess/README.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md) + + +## Model Experience + +None, as this package handles user terminal interaction without adding model input. + +#### KV Cache effect + +None; terminal output travels only between the browser and Host. + +## Known Limitations and Deferred Work + + + +- Processes and screens survive browser reloads, but not Host or Session owner disposal. There is no durable terminal restoration or automatic shell respawn. +- The subprocess provider determines native PTY availability and process-tree cleanup guarantees. Finding an executable does not prove PTY allocation will succeed. +- Screen recovery retains bounded history, not a complete transcript. Only one attachment at a time can write or resize. + + +### Dev Note + +
+Working context for maintainers — click to expand + +No runtime invariant companion is published. One owner orders terminal metadata and screen updates; the provider exposes no independently observed dimensions to compare. + +
diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md new file mode 100644 index 0000000000..fef6e6b45c --- /dev/null +++ b/packages/api/terminal-controller/README.zh.md @@ -0,0 +1,90 @@ +--- +description: "用户交互式终端:执行环境默认 shell、有界屏幕恢复和类型化 Remote 控制。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-terminal-controller + +[English](README.md) | 中文 + +## 概述 + +从 Web 侧栏在会话工作区打开执行环境的默认 shell。重新连接已有进程,并关闭 provider 管理的完整进程范围。终端输出不进入 Agent 对话记录。保留终端会占用进程和有界屏幕缓存。 + +## 目录 + +- [使用此包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。 + +新终端使用执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。provider 在创建时验证可执行文件;解析失败会直接报告,不尝试其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。 + +| 配置 | 默认值 | 含义 | +|---|---|---| +| `shell` | 省略 | 使用执行环境默认 shell,或指定一个 profile | +| `maxTerminals` | `8` | 每个 Session 保留的终端和创建请求上限 | +| `maxCols`、`maxRows` | `500`、`200` | PTY 最大尺寸 | +| `scrollback` | `1000` | 屏幕历史行数 | +| `maxBufferedBytes` | `2097152` | 单个订阅者的输出排队上限 | +| `maxInputBytes` | `65536` | 单次输入请求的字节上限 | +| `disposeGraceMs` | `1000` | provider 终止宽限期,单位毫秒 | + + +## 了解实现 + +
+实现细节 + +Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 + +最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 + +Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 + +关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功后删除;启动时重试已保存的请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。 + +
+ + +## 延伸阅读 + +- [Subprocess](../../subprocess/subprocess/README.zh.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.zh.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md) + + +## 模型体验 + +无;此包只处理用户交互式终端,不向模型请求添加内容。 + +#### KV 缓存影响 + +无;终端输出只在浏览器与 Host 之间传输。 + +## 已知限制与延期工作 + + + +- 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。 +- 原生 PTY 可用性和进程树清理保证由 subprocess provider 决定。找到可执行文件并不保证 PTY 分配成功。 +- 屏幕恢复只保留有界历史,不保留完整记录。同一时刻只有一个连接可输入或调整尺寸。 + + +### 开发备注 + +
+维护说明 + +不发布运行时 invariant companion。终端元数据与屏幕更新由同一对象按序写入,没有独立的进程尺寸观测可供比较。 + +
diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json new file mode 100644 index 0000000000..2427e57898 --- /dev/null +++ b/packages/api/terminal-controller/package.json @@ -0,0 +1,92 @@ +{ + "name": "@deepseek-ai/dsh-api-terminal-controller", + "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control", + "version": "0.1.5-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/terminal-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-gateway" + ], + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-deque": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "@xterm/headless": "^6.0.0", + "@xterm/addon-serialize": "^0.14.0", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ] +} diff --git a/packages/api/terminal-controller/src/client/close-requests.ts b/packages/api/terminal-controller/src/client/close-requests.ts new file mode 100644 index 0000000000..f91581a4b5 --- /dev/null +++ b/packages/api/terminal-controller/src/client/close-requests.ts @@ -0,0 +1,73 @@ +/** Unfinished close requests survive reload independently of the removed sidebar tabs. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WebTerminalId } from '../types.ts' + +/** An explicit cleanup request; no process or open-tab metadata is mirrored here. */ +export interface TerminalCloseRequest { + readonly sessionId: SessionId + readonly id: WebTerminalId + readonly title: string +} + +const PREFIX = 'dsh.terminal.close.v1.' + +/** Each request has its own storage key, so other browser windows cannot overwrite its cleanup. */ +export class TerminalCloseRequests { + private readonly requests = new Map() + + constructor() { + try { + if (typeof localStorage === 'undefined') return + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index) + if (key?.startsWith(PREFIX)) this.load(key) + } + } catch (error) { console.error('Terminal cleanup recovery failed:', error) } + } + + /** + * Read cleanup work still awaiting Host confirmation. + * @returns unfinished requests owned by this browser instance. + */ + pending(): readonly TerminalCloseRequest[] { return [...this.requests.values()] } + + /** + * Retain cleanup across reload before removing a tab. + * @param request - close intent to save before removing its tab. + */ + save(request: TerminalCloseRequest): void { + this.requests.set(request.id, request) + try { + if (typeof localStorage !== 'undefined') localStorage.setItem(PREFIX + request.id, JSON.stringify(request)) + } catch (error) { console.error('Terminal cleanup persistence failed:', error) } + } + + /** + * Forget confirmed cleanup in memory and browser storage. + * @param id - terminal whose Host cleanup succeeded. + */ + remove(id: WebTerminalId): void { + this.requests.delete(id) + try { + if (typeof localStorage !== 'undefined') localStorage.removeItem(PREFIX + id) + } catch (error) { console.error('Terminal cleanup persistence failed:', error) } + } + + private load(key: string): void { + try { + const raw = localStorage.getItem(key) + if (raw === null) return + const parsed: unknown = JSON.parse(raw) + if (!isRequest(parsed) || key !== PREFIX + parsed.id) throw new Error('Invalid terminal cleanup request') + this.requests.set(parsed.id, parsed) + } catch (error) { console.error('Terminal cleanup recovery failed:', error) } + } +} + +function isRequest(value: unknown): value is TerminalCloseRequest { + if (typeof value !== 'object' || value === null) return false + const request = value as Record + return typeof request.sessionId === 'string' && request.sessionId.length > 0 + && typeof request.id === 'string' && /^[\w-]{1,128}$/u.test(request.id) + && typeof request.title === 'string' +} diff --git a/packages/api/terminal-controller/src/client/index.ts b/packages/api/terminal-controller/src/client/index.ts new file mode 100644 index 0000000000..b49d86c437 --- /dev/null +++ b/packages/api/terminal-controller/src/client/index.ts @@ -0,0 +1,145 @@ +/** Client terminal model service; views are keyed independently from Host terminal identities. */ +import { Service, type Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-api-gateway/client' +import { TerminalView, type TerminalRemote } from './model.ts' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { WebTerminalId, WebTerminalInfo } from '../types.ts' +import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts' + +export type { TerminalView, TerminalViewState, TerminalRenderFrame, TerminalRemote } from './model.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free browser terminal views and explicit process cleanup. */ + webTerminals: ClientTerminals + } +} + +/** A failed background close that can be retried without restoring its tab. */ +export interface TerminalCloseFailure { + readonly id: WebTerminalId + readonly title: string + readonly message: string +} + +/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */ +export class ClientTerminals extends Service { + /** Failed cleanup tasks; successful and in-progress closes have no visible notification. */ + readonly closeFailures: SnapshotStore = createSnapshotStore([]) + private readonly requests = new TerminalCloseRequests() + private readonly closing = new Map>() + private readonly closed = new Set(this.requests.pending().map(request => request.id)) + private disposed = false + private readonly views = new Map>() + + /** + * @param ctx - Client root Context with Gateway and terminal Remote namespace. + * @param remote - generated terminal namespace. + */ + constructor(ctx: Context, private readonly remote: TerminalRemote) { + super(ctx, 'webTerminals') + ctx.effect(() => async () => { + this.disposed = true + for (const views of this.views.values()) for (const view of views.values()) view.dispose() + this.views.clear() + await Promise.all(this.closing.values()) + }, 'terminal-controller.client.views') + for (const request of this.requests.pending()) this.cleanup(request) + } + + /** + * Return the stable model for one sidebar occurrence. + * @param sessionId - owning Session. + * @param key - sidebar occurrence key. + * @param terminalId - existing Host identity when restoring a listed terminal. + * @returns its observable state and terminal commands. + */ + view(sessionId: SessionId, key: string, terminalId?: WebTerminalId): TerminalView { + let views = this.views.get(sessionId) + if (views === undefined) { views = new Map(); this.views.set(sessionId, views) } + let view = views.get(key) + if (view === undefined) { + const id = terminalId ?? randomUUID() as WebTerminalId + view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === undefined) + views.set(key, view) + void view.refresh() + } + return view + } + + /** + * Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload. + * @param sessionId - owning Session. + * @param key - sidebar occurrence key, including an inactive restored tab. + * @param terminalId - restored identity if the tab has no model yet. + */ + close(sessionId: SessionId, key: string, terminalId?: WebTerminalId): void { + const views = this.views.get(sessionId) + const view = views?.get(key) + const id = view?.id ?? terminalId + if (id === undefined) return + const request: TerminalCloseRequest = { sessionId, id, title: view?.state.getSnapshot().title ?? key } + this.closed.add(id) + this.requests.save(request) + views?.delete(key) + if (views?.size === 0) this.views.delete(sessionId) + this.cleanup(request, view) + } + + /** + * Query Host terminals that have neither a tab in this page nor an unfinished close. + * @param sessionId - Session being displayed. + * @returns terminals available for opening as recovered tabs. + */ + async recover(sessionId: SessionId): Promise { + const result = await this.remote.list(sessionId) + if (!result.ok) throw new Error(result.error.message) + const held = new Set([...(this.views.get(sessionId)?.values() ?? [])].map(view => view.id)) + const closing = new Set(this.requests.pending().map(request => request.id)) + return result.value.filter(info => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id)) + } + + /** + * Retry a saved close request without reopening its tab. + * @param id - failed terminal identity. + */ + retryClose(id: WebTerminalId): void { + const record = this.requests.pending().find(item => item.id === id) + if (record !== undefined) this.cleanup(record) + } + + private cleanup(record: TerminalCloseRequest, view?: TerminalView): void { + if (this.closing.has(record.id) || this.disposed) return + this.closeFailures.set(this.closeFailures.getSnapshot().filter(failure => failure.id !== record.id)) + const pending = (async () => { + if (view !== undefined) await view.close() + else { + const result = await this.remote.close(record.sessionId, record.id) + if (!result.ok) throw new Error(result.error.message) + } + this.requests.remove(record.id) + })().catch((error: unknown) => { + if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), { + id: record.id, title: record.title, + message: error instanceof Error ? error.message : String(error), + }]) + }).finally(() => { + view?.dispose() + this.closing.delete(record.id) + }) + this.closing.set(record.id, pending) + } +} + +/** Required Client transport and terminal namespace. */ +export const inject = ['remote', 'remote.terminal'] + +/** + * Install the Client terminal models. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + new ClientTerminals(ctx, ctx.remote.terminal) +} diff --git a/packages/api/terminal-controller/src/client/model.ts b/packages/api/terminal-controller/src/client/model.ts new file mode 100644 index 0000000000..8c4336f3b9 --- /dev/null +++ b/packages/api/terminal-controller/src/client/model.ts @@ -0,0 +1,284 @@ +/** React-free browser terminal state and reconnecting Remote-stream ownership. */ +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + TerminalAttachmentId, TerminalEnvironment, TerminalFrame, + WebTerminalId, WebTerminalInfo, +} from '../types.ts' + +/** The generated terminal namespace's browser-facing operations. */ +export type TerminalRemote = ClientRemote['terminal'] + +/** One screen write awaiting the DOM emulator's callback. */ +export interface TerminalRenderFrame { + readonly revision: number + readonly frame: Extract +} + +/** Observable state of one sidebar occurrence. */ +export interface TerminalViewState { + readonly phase: 'idle' | 'loading' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed' + readonly environment?: TerminalEnvironment | undefined + readonly title?: string | undefined + readonly info?: WebTerminalInfo | undefined + readonly writable: boolean + readonly render?: TerminalRenderFrame | undefined + readonly error?: string | undefined +} + +/** A view survives DOM unmount; its process only ends on explicit close. */ +export class TerminalView { + /** Observable controls, process metadata and the next screen update awaiting acknowledgement. */ + readonly state: SnapshotStore = createSnapshotStore({ phase: 'idle', writable: false }) + private readonly lifetime = new AbortController() + private stream: RemoteStream | undefined + private mounted = false + private attachmentId: TerminalAttachmentId | undefined + private pendingRender: { revision: number; resolve: () => void } | undefined + private revision = 0 + private creation: Promise | undefined + private loading: Promise | undefined + private closing: Promise | undefined + private writes: Promise = Promise.resolve() + private queuedInput = 0 + + /** + * @param sessionId - Session owning the terminal. + * @param remote - typed terminal Remote operations. + * @param gateway - reconnecting stream factory. + * @param id - Host terminal identity, reused when recovering an item from its Session list. + * @param createWhenMissing - allow allocation only for a new tab, never a listed terminal. + */ + constructor( + private readonly sessionId: SessionId, + private readonly remote: TerminalRemote, + private readonly gateway: Pick, + readonly id: WebTerminalId, + private readonly createWhenMissing = true, + ) {} + + /** + * Attach the DOM lifetime, starting the default shell or reconnecting the saved process. + * @returns a detach callback that leaves the terminal process alive. + */ + mount(): () => void { + this.mounted = true + if (this.state.getSnapshot().info === undefined) void this.refresh() + else this.connect() + return () => { + this.mounted = false + this.detach() + } + } + + /** + * Start or recover this tab, deduplicating mounts and retries during allocation. + * Only a new tab may allocate a shell; listed terminals cannot be silently replaced. + * @returns after environment lookup and creation or recovery settle. + */ + refresh(): Promise { + if (this.loading !== undefined) return this.loading + if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() + this.patch({ phase: 'loading', error: undefined }) + this.loading = (async () => { + const [environment, available] = await Promise.all([ + this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId), + ]) + if (this.lifetime.signal.aborted || this.closing !== undefined) return + this.patch({ environment: valueOf(environment) }) + const info = valueOf(available).find(item => item.id === this.id) + if (info !== undefined) this.adopt(info) + else if (this.createWhenMissing) await this.create(valueOf(environment)) + else throw new Error('This terminal no longer exists on the Host. Open a new terminal.') + })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined }) + return this.loading + } + + private async create(environment: TerminalEnvironment): Promise { + this.patch({ phase: 'creating', error: undefined }) + this.creation = (async () => { + const info = valueOf(await this.remote.create(this.sessionId, { + id: this.id, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows), + }, this.lifetime.signal)) + if (!this.lifetime.signal.aborted) this.adopt(info) + })().finally(() => { this.creation = undefined }) + await this.creation + } + + private adopt(info: WebTerminalInfo): void { + this.patch({ info, title: info.title }) + if (this.mounted && this.closing === undefined) this.connect() + } + + /** Reattach with a fresh screen and regain input control. */ + connect(): void { + const info = this.state.getSnapshot().info + if (info === undefined || !this.mounted || this.closing !== undefined || this.lifetime.signal.aborted) return + this.detach() + const stream = this.gateway.$stream({ + name: 'Browser terminal output', + open: (signal) => { + const attachmentId = randomUUID() as TerminalAttachmentId + this.attachmentId = attachmentId + return this.remote.follow(this.sessionId, info.id, attachmentId, signal) + }, + ended: () => new Error('Terminal attachment ended; reconnect to recover its state'), + carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) }, + }) + this.stream = stream + this.patch({ phase: 'connecting', writable: false, error: undefined, render: undefined }) + void this.consume(stream) + } + + /** + * Release the next stream item after xterm has parsed this frame. + * @param revision - locally delivered render revision. + */ + acknowledge(revision: number): void { + if (this.pendingRender?.revision !== revision) return + this.pendingRender.resolve() + this.pendingRender = undefined + } + + /** + * Serialize raw input so concurrent RPC requests cannot reorder keystrokes. + * @param data - input from the terminal emulator. + */ + write(data: string): void { + const state = this.state.getSnapshot() + const attachmentId = this.attachmentId + if (!state.writable || state.info === undefined || attachmentId === undefined) return + const bytes = new TextEncoder().encode(data).byteLength + if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new Error('Terminal input buffer is full')); return } + this.queuedInput += bytes + const id = state.info.id + this.writes = this.writes.then(async () => { + if (this.attachmentId !== attachmentId) return + valueOf(await this.remote.write(this.sessionId, id, attachmentId, data)) + }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes }) + } + + /** + * Resize only from the currently writable view. + * @param cols - measured column count. + * @param rows - measured row count. + */ + resize(cols: number, rows: number): void { + const state = this.state.getSnapshot() + const attachmentId = this.attachmentId + if (!state.writable || state.info === undefined || attachmentId === undefined) return + if (state.info.cols === cols && state.info.rows === rows) return + const id = state.info.id + cols = Math.min(cols, state.environment?.maxCols ?? cols) + rows = Math.min(rows, state.environment?.maxRows ?? rows) + this.writes = this.writes.then(async () => { + if (this.attachmentId !== attachmentId) return + valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows)) + }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }) + } + + /** + * Update the Host terminal's display name. + * @param title - user-entered terminal title. + * @returns after the rename settles and its result is reflected in view state. + */ + async rename(title: string): Promise { + if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted) return + try { + valueOf(await this.remote.rename(this.sessionId, this.id, title)) + const current = this.state.getSnapshot().info + this.patch({ ...(current === undefined ? {} : { info: { ...current, title: title.trim() } }), title: title.trim() }) + } catch (error) { this.fail(error) } + } + + /** + * Explicitly terminate this view's process independently of its DOM lifetime. + * @returns after Host process cleanup succeeds; failures remain retryable by the owner. + */ + close(): Promise { + if (this.closing !== undefined) return this.closing + this.patch({ phase: 'closing', writable: false, error: undefined }) + this.detach() + this.closing = (async () => { + // Even a refused or lost creation response may leave an allocation to close. + await this.creation?.catch(() => {}) + valueOf(await this.remote.close(this.sessionId, this.id)) + this.detach() + this.patch({ phase: 'closed', writable: false }) + })().catch((error: unknown) => { this.closing = undefined; this.fail(error); throw error }) + return this.closing + } + + /** Stop Client work on plugin unload without closing Host terminals. */ + dispose(): void { + this.mounted = false + this.lifetime.abort() + this.detach() + } + + private detach(): void { + const previous = this.stream + this.stream = undefined + this.attachmentId = undefined + this.pendingRender?.resolve() + this.pendingRender = undefined + if (previous !== undefined) void previous.dispose() + } + + private async consume(stream: RemoteStream): Promise { + let generation = 0 + let sequence = 0 + try { + for await (const item of stream) { + if (this.stream !== stream) return + const frame = item.value + if (generation !== item.generation) { + if (frame.type !== 'snapshot') throw new Error('Terminal output generation is missing its screen snapshot') + generation = item.generation + sequence = frame.sequence + item.accept() + } else if (frame.type === 'output') { + if (frame.sequence !== sequence + 1) throw new Error('Terminal output sequence has a gap') + sequence = frame.sequence + } else if (frame.type === 'snapshot') throw new Error('Unexpected terminal screen snapshot') + if (frame.type !== 'output') { + this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId }) + } + if (frame.type !== 'state') { + const revision = ++this.revision + await new Promise((resolve) => { + this.pendingRender = { revision, resolve } + const aborted = (): void => { this.acknowledge(revision) } + item.signal.addEventListener('abort', aborted, { once: true }) + this.pendingRender.resolve = () => { item.signal.removeEventListener('abort', aborted); resolve() } + this.patch({ render: { revision, frame } }) + if (item.signal.aborted) aborted() + }) + } + } + } catch (error) { + if (this.stream === stream) { + if (this.state.getSnapshot().info?.state === 'exited') this.patch({ phase: 'closed', writable: false }) + else this.fail(error) + } + } + } + + private patch(patch: Partial): void { + if (this.lifetime.signal.aborted) return + this.state.set({ ...this.state.getSnapshot(), ...patch }) + } + + private fail(error: unknown): void { + this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, error: error instanceof Error ? error.message : String(error) }) + } +} + +function valueOf(result: RemoteResult): T { + if (!result.ok) throw new Error(result.error.message) + return result.value +} diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts new file mode 100644 index 0000000000..9efb1d07df --- /dev/null +++ b/packages/api/terminal-controller/src/index.ts @@ -0,0 +1,341 @@ +/** Session-scoped browser terminals over the composed subprocess and sandbox providers. */ +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-session-projection' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { resolveShell } from './shells.ts' +import { BrowserTerminal } from './terminal.ts' +import type { + TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, + WebTerminalId, WebTerminalInfo, +} from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Interactive user terminals, separate from the Agent terminal tool registry. */ + terminalController: TerminalController + } +} + +/** Deployment limits and an optional shell profile. */ +export interface Config { + /** Explicit shell profile; omission uses the execution environment's default shell. */ + readonly shell?: { + /** Executable path or PATH name, verified by the subprocess provider. */ + path: string + /** User-visible profile name. */ + name: string + /** Arguments passed to the interactive shell. */ + args: string[] + } | undefined + /** Maximum retained terminals and pending allocations per Session. */ + readonly maxTerminals: number + /** Maximum terminal width in columns. */ + readonly maxCols: number + /** Maximum terminal height in rows. */ + readonly maxRows: number + /** Screen history rows retained for reconnecting clients. */ + readonly scrollback: number + /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */ + readonly maxBufferedBytes: number + /** Maximum UTF-8 bytes in one input request. */ + readonly maxInputBytes: number + /** Provider process-termination grace period in milliseconds. */ + readonly disposeGraceMs: number +} + +interface OwnedSession { + readonly lifetime: AbortController + readonly closedIds: Set + cleanup?: Promise + readonly terminals: Map + readonly pending: Map> + readonly allocations: Map +} + +/** Typed Remote control of transient Session-owned terminal processes. */ +export class TerminalController extends TypertRemoteService { + static inject = ['subprocess', 'sandboxPolicy', 'sessionProjections', 'typert'] + static Config: z = z.object({ + shell: z.union([z.object({ + path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]), + }), z.const(undefined)]), + maxTerminals: z.number().step(1).min(1).default(8), + maxCols: z.number().step(1).min(2).default(500), + maxRows: z.number().step(1).min(1).default(200), + scrollback: z.number().step(1).min(0).default(1000), + maxBufferedBytes: z.number().step(1).min(1024).default(2 * 1024 * 1024), + maxInputBytes: z.number().step(1).min(1).default(64 * 1024), + disposeGraceMs: z.number().step(1).min(1).default(1000), + }) + + private readonly owners = new Map() + private readonly lifetime = new AbortController() + + /** + * @param ctx - Host context carrying typed Remote and execution providers. + * @param config - validated terminal limits and optional shell profile. + */ + constructor(ctx: Context, private readonly config: Config) { + super(ctx, 'terminalController', { namespace: 'terminal' }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + if (event.type !== 'sandbox/mode') return + const owner = this.owners.get(session.id) + if (owner === undefined || owner.terminals.size + owner.pending.size + owner.allocations.size === 0) return + const current = ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? ctx.sandboxPolicy.defaultMode + if (event.data.mode !== current) throw new Error('Close browser terminals before changing the Session sandbox mode') + }, { global: true }) + ctx.effect(() => async () => { + this.lifetime.abort(new Error('Terminal controller disposed')) + const results = await Promise.allSettled([...this.owners].map(([id, owner]) => this.disposeOwner(id, owner))) + const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown) + if (errors.length > 0) throw new AggregateError(errors, 'Browser terminal cleanup failed') + }, 'terminal-controller.processes') + } + + /** + * Read the Session working directory and terminal limits without resolving a shell. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns the Session workspace directory and terminal limits. + */ + @Remote + environment(agent: Agent, signal: AbortSignal): TerminalEnvironment { + signal.throwIfAborted() + const { sandboxPolicy } = this.execution(agent) + return { cwd: sandboxPolicy.resolve({ session: agent.session }).workspaceRoot, + maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols, + maxRows: this.config.maxRows, scrollback: this.config.scrollback } + } + + /** + * List running and exited terminals available for reattachment. + * @param agent - Session owner supplied by the Gateway. + * @returns terminals retained for this Host lifetime. + */ + @Remote + list(agent: Agent): WebTerminalInfo[] { + const owner = this.owners.get(agent.id) + if (owner === undefined) return [] + return [...owner.terminals.values(), ...owner.allocations.values()].map(terminal => terminal.info) + } + + /** + * Allocate an interactive shell once for a caller-generated identity. + * @param agent - Session owner supplied by the Gateway. + * @param request - initial dimensions and idempotency identity. + * @param signal - allocation cancellation; committed terminals survive disconnection. + * @returns the existing or newly committed terminal. + */ + @Remote + async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise { + this.lifetime.signal.throwIfAborted() + if (!/^[\w-]{1,128}$/u.test(request.id)) throw new Error('Invalid terminal identity') + this.dimensions(request.cols, request.rows) + const owner = this.owner(agent) + owner.lifetime.signal.throwIfAborted() + this.requireOpen(owner, request.id) + const existing = owner.terminals.get(request.id) + if (existing !== undefined) return existing.info + const pending = owner.pending.get(request.id) + if (pending !== undefined) { + const terminal = await pending + this.requireOpen(owner, request.id) + return terminal.info + } + if (owner.allocations.has(request.id)) throw new Error('Close the failed terminal allocation before creating it again') + if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new Error('Session terminal limit reached') + const allocation = this.spawn(agent, owner, request, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal])) + owner.pending.set(request.id, allocation) + try { + const terminal = await allocation + owner.terminals.set(request.id, terminal) + owner.allocations.delete(request.id) + this.requireOpen(owner, request.id) + return terminal.info + } finally { + owner.pending.delete(request.id) + } + } + + /** + * Attach to a terminal without binding its process lifetime to the transport. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - new exclusive input attachment. + * @param signal - physical stream cancellation. + * @returns screen recovery followed by output and metadata changes. + */ + @Remote({ mode: 'stream' }) + follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { + if (!/^[\w-]{1,128}$/u.test(attachmentId)) throw new Error('Invalid terminal attachment identity') + return this.terminal(agent, id).follow(attachmentId, signal) + } + + /** + * Deliver raw input, including Tab completion and control characters. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param data - input bytes represented as UTF-8 text. + * @returns after provider input acceptance. + */ + @Remote + async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise { + if (Buffer.byteLength(data, 'utf8') > this.config.maxInputBytes) throw new Error('Terminal input exceeds the configured limit') + await this.terminal(agent, id).write(attachmentId, data) + } + + /** + * Update the dimensions of the PTY and recovery screen. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param attachmentId - current writable attachment. + * @param cols - column count. + * @param rows - row count. + * @returns after the resize completes. + */ + @Remote + async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise { + this.dimensions(cols, rows) + await this.terminal(agent, id).resize(attachmentId, cols, rows) + } + + /** + * Rename a terminal without changing its shell. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @param title - nonempty display title, at most 120 characters. + */ + @Remote + rename(agent: Agent, id: WebTerminalId, title: string): void { + if (title.trim().length === 0 || title.length > 120) throw new Error('Terminal title must contain 1–120 characters') + this.terminal(agent, id).rename(title.trim()) + } + + /** + * Close an identity to future creation and kill its process range; repeated closes succeed. + * @param agent - Session owner supplied by the Gateway. + * @param id - terminal identity. + * @returns after provider cleanup succeeds. A failure retains the terminal for retry. + */ + @Remote + async close(agent: Agent, id: WebTerminalId): Promise { + const owner = this.owner(agent) + owner.closedIds.add(id) + await owner.pending.get(id)?.catch(() => { /* Creation reports its failure; close still owns any allocated process. */ }) + const terminal = owner.terminals.get(id) + if (terminal !== undefined) { + await terminal.close() + owner.terminals.delete(id) + } else { + const allocation = owner.allocations.get(id) + if (allocation === undefined) return + await allocation.handle.terminate() + owner.allocations.delete(id) + } + } + + private owner(agent: Agent): OwnedSession { + let owner = this.owners.get(agent.id) + if (owner === undefined) { + owner = { terminals: new Map(), pending: new Map(), allocations: new Map(), closedIds: new Set(), lifetime: new AbortController() } + this.owners.set(agent.id, owner) + const owned = owner + agent.ctx.effect(() => async () => { await this.disposeOwner(agent.id, owned) }, 'terminal-controller.owner') + } + return owner + } + + private disposeOwner(id: SessionId, owner: OwnedSession): Promise { + if (owner.cleanup !== undefined) return owner.cleanup + owner.lifetime.abort(new Error('Terminal Session owner disposed')) + owner.cleanup = (async () => { + await Promise.allSettled(owner.pending.values()) + const results = await Promise.allSettled([ + ...[...owner.terminals.values()].map(terminal => terminal.close()), + ...[...owner.allocations.values()].map(allocation => allocation.handle.terminate()), + ]) + const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown) + if (errors.length > 0) throw new AggregateError(errors, 'Session terminal cleanup failed') + owner.terminals.clear() + owner.allocations.clear() + this.owners.delete(id) + })().catch((error: unknown) => { delete owner.cleanup; throw error }) + return owner.cleanup + } + + private terminal(agent: Agent, id: WebTerminalId): BrowserTerminal { + const terminal = this.owners.get(agent.id)?.terminals.get(id) + if (terminal === undefined) throw new Error('Terminal no longer exists in this Session') + return terminal + } + + private requireOpen(owner: OwnedSession, id: WebTerminalId): void { + if (owner.closedIds.has(id)) throw new Error('Terminal was closed in this Session') + } + + private dimensions(cols: number, rows: number): void { + if (!Number.isSafeInteger(cols) || cols < 2 || cols > this.config.maxCols + || !Number.isSafeInteger(rows) || rows < 1 || rows > this.config.maxRows) throw new Error('Terminal dimensions exceed the configured limits') + } + + private execution(agent: Agent): { subprocess: Context['subprocess']; sandboxPolicy: Context['sandboxPolicy'] } { + // The Agent context selects execution providers but does not inject consumer services. + const subprocess = agent.ctx.get('subprocess') + const sandboxPolicy = agent.ctx.get('sandboxPolicy') + if (subprocess === undefined || sandboxPolicy === undefined) throw new Error('The Session execution environment requires subprocess and sandbox policy providers') + return { subprocess, sandboxPolicy } + } + + private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise { + const environment = this.environment(agent, signal) + const { subprocess, sandboxPolicy } = this.execution(agent) + const shell = await resolveShell(subprocess, this.config.shell, signal) + const policy = sandboxPolicy.resolve({ session: agent.session }) + let argv = [shell.path, ...shell.args] + if (policy.mode !== 'danger-full-access') { + const sandbox = agent.ctx.get('sandbox') + if (sandbox === undefined) throw new Error('The Session sandbox mode requires an execution sandbox provider') + argv = sandbox.confine(argv, { ...policy, mode: policy.mode }).argv + } + const handle = await subprocess.spawnTerminal({ + argv, cwd: environment.cwd, cols: request.cols, rows: request.rows, + terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id }, + graceMs: this.config.disposeGraceMs, signal, + }) + const allocation = { + handle, + info: { + id: request.id, shell, title: shell.name, cwd: environment.cwd, + cols: request.cols, rows: request.rows, state: 'running', exitCode: null, + } as WebTerminalInfo, + } + owner.allocations.set(request.id, allocation) + try { + signal.throwIfAborted() + return new BrowserTerminal(handle, allocation.info, this.config.scrollback, this.config.maxBufferedBytes) + } catch (error) { + allocation.info = { ...allocation.info, state: 'failed', error: error instanceof Error ? error.message : String(error) } + try { + await handle.terminate() + owner.allocations.delete(request.id) + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Terminal allocation cleanup failed') + } + throw error + } + } +} + +/** Browser terminal service plugin. */ +export default TerminalController diff --git a/packages/api/terminal-controller/src/shells.ts b/packages/api/terminal-controller/src/shells.ts new file mode 100644 index 0000000000..284a46f319 --- /dev/null +++ b/packages/api/terminal-controller/src/shells.ts @@ -0,0 +1,28 @@ +/** Shell selection and executable verification use the target execution provider. */ +import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import type { TerminalShell } from './types.ts' + +/** + * Resolve the configured shell or the execution environment's default shell. + * @param subprocess - target execution provider. + * @param configured - optional profile overriding the environment's default shell. + * @param signal - resolution cancellation. + * @returns one verified shell; a declared default that cannot resolve rejects. + */ +export async function resolveShell( + subprocess: SubprocessRuntime, configured: TerminalShell | undefined, signal: AbortSignal, +): Promise { + let shell = configured + if (shell === undefined) { + const environment = await subprocess.terminalEnvironment(signal) + shell = profile(environment.defaultShell ?? (environment.platform === 'windows' ? 'cmd.exe' : '/bin/sh')) + } + const path = await subprocess.resolveExecutable(shell.path, undefined, signal) + return { ...shell, path } +} + +function profile(path: string): TerminalShell { + const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) + const kind = name.toLowerCase().replace(/\.exe$/u, '') + return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] } +} diff --git a/packages/api/terminal-controller/src/stream.ts b/packages/api/terminal-controller/src/stream.ts new file mode 100644 index 0000000000..4ee89f254f --- /dev/null +++ b/packages/api/terminal-controller/src/stream.ts @@ -0,0 +1,75 @@ +/** A bounded output queue for one Remote stream generation. */ +import { Deque } from '@deepseek-ai/dsh-deque' +import type { TerminalFrame } from './types.ts' + +/** Slow followers fail explicitly; a later attachment recovers from the screen. */ +export class TerminalFollower { + private readonly queue = new Deque<{ frame: TerminalFrame; bytes: number }>() + private bytes = 0 + private wake: (() => void) | undefined + private closed = false + private finished = false + private failure: Error | undefined + + /** @param maxBytes - maximum queued UTF-8 bytes for this follower. */ + constructor(private readonly maxBytes: number) {} + + /** + * Queue a frame or fail this follower when its byte limit is exceeded. + * @param frame - next ordered frame. + */ + push(frame: TerminalFrame): void { + if (this.closed || this.finished) return + const bytes = Buffer.byteLength(JSON.stringify(frame), 'utf8') + if (this.bytes + bytes > this.maxBytes) { + this.failure = new Error('Terminal output consumer exceeded its buffer; reconnect to recover the current screen') + this.close() + return + } + this.queue.pushBack({ frame, bytes }) + this.bytes += bytes + this.wake?.() + } + + /** Finish after delivering every queued frame, including the final exit state. */ + finish(): void { + this.finished = true + this.wake?.() + } + + /** Stop this follower without stopping its terminal. */ + close(): void { + this.closed = true + this.queue.clear() + this.bytes = 0 + this.wake?.() + } + + /** + * Drain until detached or failed. + * @param signal - Remote generation cancellation. + * @returns ordered terminal frames. + */ + async *read(signal: AbortSignal): AsyncIterable { + const abort = (): void => { this.close() } + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) abort() + try { + while (!this.closed) { + const next = this.queue.popFront() + if (next !== undefined) { + this.bytes -= next.bytes + yield next.frame + } else { + if (this.finished) break + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } + if (this.failure !== undefined) throw this.failure + } finally { + signal.removeEventListener('abort', abort) + this.close() + } + } +} diff --git a/packages/api/terminal-controller/src/terminal.ts b/packages/api/terminal-controller/src/terminal.ts new file mode 100644 index 0000000000..103426cf72 --- /dev/null +++ b/packages/api/terminal-controller/src/terminal.ts @@ -0,0 +1,169 @@ +/** One PTY, a bounded terminal emulator and its detachable browser followers. */ +import { createRequire } from 'node:module' +import { StringDecoder } from 'node:string_decoder' +import type { Terminal as HeadlessTerminal } from '@xterm/headless' +import type { SerializeAddon as Serializer } from '@xterm/addon-serialize' +import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import { TerminalFollower } from './stream.ts' +import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts' + +const require = createRequire(import.meta.url) +const { Terminal } = require('@xterm/headless') as typeof import('@xterm/headless') +const { SerializeAddon } = require('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize') + +/** Process lifetime is independent of follower and component lifetimes. */ +export class BrowserTerminal { + private readonly screen: HeadlessTerminal + private readonly serializer: Serializer + private readonly followers = new Set() + private sequence = 0 + private operations: Promise = Promise.resolve() + private readonly drained: Promise + private closing: Promise | undefined + private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined + + /** + * @param handle - allocated terminal process range. + * @param info - initial metadata. + * @param scrollback - maximum retained scrollback rows. + * @param maxBufferedBytes - per-follower queue cap. + */ + constructor( + private readonly handle: SubprocessTerminalHandle, + public info: WebTerminalInfo, + scrollback: number, + private readonly maxBufferedBytes: number, + ) { + this.screen = new Terminal({ cols: info.cols, rows: info.rows, scrollback, allowProposedApi: true }) + this.serializer = new SerializeAddon() + this.screen.loadAddon(this.serializer) + this.drained = this.consume() + } + + /** + * Attach with exclusive input control; an older attachment becomes read-only. + * @param id - browser attachment identity. + * @param signal - attachment cancellation; never terminates the process. + * @returns a consistent screen followed by ordered output and state changes. + */ + async *follow(id: TerminalAttachmentId, signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const follower = new TerminalFollower(this.maxBufferedBytes) + const baseline = await this.enqueue(() => { + signal.throwIfAborted() + this.controller = { id, follower } + this.info = { ...this.info, controllerId: id } + this.broadcast({ type: 'state', info: this.info }) + const snapshot: TerminalFrame = { type: 'snapshot', sequence: this.sequence, screen: this.serializer.serialize(), info: this.info } + this.followers.add(follower) + return snapshot + }) + try { + yield baseline + yield* follower.read(signal) + } finally { + this.followers.delete(follower) + follower.close() + if (this.controller?.follower === follower) { + this.controller = undefined + const { controllerId: _controllerId, ...info } = this.info + this.info = info + this.broadcast({ type: 'state', info }) + } + } + } + + /** + * Send raw terminal input without command interpretation. + * @param id - current writable attachment. + * @param data - UTF-8 input, including shell completion/control keys. + * @returns when the provider accepts the input. + */ + write(id: TerminalAttachmentId, data: string): Promise { + return this.enqueue(async () => { this.requireController(id); await this.handle.write(data) }) + } + + /** + * Resize the PTY and recovery screen in the same operation order as output. + * @param id - current writable attachment. + * @param cols - validated column count. + * @param rows - validated row count. + * @returns when the provider and emulator use the new dimensions. + */ + resize(id: TerminalAttachmentId, cols: number, rows: number): Promise { + return this.enqueue(async () => { + this.requireController(id) + await this.handle.resize(cols, rows) + this.screen.resize(cols, rows) + this.info = { ...this.info, cols, rows } + this.broadcast({ type: 'state', info: this.info }) + }) + } + + /** + * Publish a display name to every attached view. + * @param title - validated user title. + */ + rename(title: string): void { + this.info = { ...this.info, title } + this.broadcast({ type: 'state', info: this.info }) + } + + /** + * Terminate the complete provider-owned process range before releasing its screen. + * @returns after process cleanup and final output drainage; failures remain retryable. + */ + close(): Promise { + if (this.closing !== undefined) return this.closing + this.closing = (async () => { + await this.handle.terminate() + await this.drained + for (const follower of this.followers) follower.finish() + this.followers.clear() + this.screen.dispose() + })().catch((error: unknown) => { this.closing = undefined; throw error }) + return this.closing + } + + private requireController(id: TerminalAttachmentId): void { + if (this.closing !== undefined || this.info.state !== 'running') throw new Error('Terminal is not running') + if (this.controller?.id !== id) throw new Error('Terminal input is controlled by another attachment') + } + + private broadcast(frame: TerminalFrame): void { + for (const follower of this.followers) follower.push(frame) + } + + private enqueue(operation: () => T | Promise): Promise { + const pending = this.operations.then(operation) + this.operations = pending.catch(() => { /* The caller owns this operation's failure; later cleanup must still run. */ }) + return pending + } + + private async consume(): Promise { + const decoder = new StringDecoder('utf8') + const outcome = this.handle.done.then(value => ({ value }), (error: unknown) => ({ error })) + try { + for await (const chunk of this.handle.output) { + // Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks. + const data = decoder.write(chunk as Buffer) + await this.output(data) + } + await this.output(decoder.end()) + const result = await outcome + if ('error' in result) throw result.error + this.info = { ...this.info, state: 'exited', exitCode: result.value.exitCode } + } catch (error) { + this.info = { ...this.info, state: 'failed', error: error instanceof Error ? error.message : String(error) } + } + this.broadcast({ type: 'state', info: this.info }) + } + + private async output(data: string): Promise { + if (data.length === 0) return + await this.enqueue(async () => { + await new Promise((resolve) => { this.screen.write(data, resolve) }) + this.broadcast({ type: 'output', sequence: ++this.sequence, data }) + }) + } +} diff --git a/packages/api/terminal-controller/src/types.ts b/packages/api/terminal-controller/src/types.ts new file mode 100644 index 0000000000..94cc827d4c --- /dev/null +++ b/packages/api/terminal-controller/src/types.ts @@ -0,0 +1,51 @@ +/** Browser terminal identities, metadata and screen-stream frames. */ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** A terminal identity scoped to one Session and one Host lifetime. */ +export type WebTerminalId = Branded<'WebTerminalId'> +/** An attachment allowed to write and resize one terminal. */ +export type TerminalAttachmentId = Branded<'TerminalAttachmentId'> + +/** An executable shell verified in the subprocess provider's execution environment. */ +export interface TerminalShell { + readonly path: string + readonly args: readonly string[] + readonly name: string +} + +/** Working directory and limits shared by new and restored terminals. */ +export interface TerminalEnvironment { + readonly cwd: string + readonly maxInputBytes: number + readonly maxCols: number + readonly maxRows: number + readonly scrollback: number +} + +/** Host-owned terminal state; process exit never creates a replacement shell. */ +export interface WebTerminalInfo { + readonly id: WebTerminalId + readonly title: string + readonly shell: TerminalShell + /** Initial working directory; shell directory changes do not update this field. */ + readonly cwd: string + readonly cols: number + readonly rows: number + readonly state: 'running' | 'exited' | 'failed' + readonly exitCode: number | null + readonly error?: string + readonly controllerId?: TerminalAttachmentId +} + +/** Create is idempotent for an open identity; closed identities cannot be recreated. */ +export interface TerminalCreateRequest { + readonly id: WebTerminalId + readonly cols: number + readonly rows: number +} + +/** Every attachment begins with a complete bounded screen, then ordered output. */ +export type TerminalFrame = + | { readonly type: 'snapshot'; readonly sequence: number; readonly screen: string; readonly info: WebTerminalInfo } + | { readonly type: 'output'; readonly sequence: number; readonly data: string } + | { readonly type: 'state'; readonly info: WebTerminalInfo } diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts new file mode 100644 index 0000000000..09859f9a8c --- /dev/null +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -0,0 +1,508 @@ +/** Session identity, allocation races, confinement and real PTY behavior. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PassThrough } from 'node:stream' +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import { SubprocessExecutableNotFoundError, type SubprocessRuntime, type SubprocessTerminalEnvironment, type SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalController, type Config } from '../src/index.ts' +import { resolveShell } from '../src/shells.ts' +import type { TerminalAttachmentId, WebTerminalId } from '../src/types.ts' + +const roots: Context[] = [] +afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) }) +const config: Config = { shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100 } +const id = 'test-terminal' as WebTerminalId +const request = { id, cols: 80, rows: 24 } +const signal = (): AbortSignal => new AbortController().signal + +function owner(ctx: Context, id = 'session'): Agent { + return { id: id as SessionId, ctx, session: { id: id as SessionId } } as unknown as Agent +} + +function fixture(overrides: Partial = {}) { + const ctx = new Context() + roots.push(ctx) + const effects = vi.spyOn(ctx.fiber, 'effect') + const sandboxPolicy = { defaultMode: 'danger-full-access', resolve: vi.fn((): SandboxExecutionPolicy => ({ mode: 'danger-full-access', workspaceRoot: '/workspace' })) } + const projections = { stateOf: vi.fn((): SandboxMode | null => null) } + ctx.provide('sandboxPolicy', sandboxPolicy as never) + ctx.provide('sessionProjections', projections as never) + const output = new PassThrough() + const done = Promise.withResolvers<{ exitCode: number; signal: null }>() + const handle = { + pid: 123, output, done: done.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}), + inspectForeground: async () => undefined, signalForeground: async () => 123, + terminate: vi.fn(async () => { output.end(); done.resolve({ exitCode: 0, signal: null }) }) } + const checked: SubprocessTerminalHandle = handle + const subprocess = { terminalEnvironment: vi.fn(async (): Promise => ({ platform: 'posix', defaultShell: '/bin/bash' })), resolveExecutable: vi.fn(async (path: string) => path), spawnTerminal: vi.fn(async () => checked) } + ctx.provide('subprocess', subprocess as never) + const controller = new TerminalController(ctx, { ...config, ...overrides }) + const disposeEffect = (label: string): Promise => { + const index = effects.mock.calls.findIndex(call => call[1] === label) + const result = effects.mock.results[index] + if (result?.type !== 'return' || typeof result.value !== 'function') throw new Error(`Missing effect: ${label}`) + return result.value() + } + return { ctx, agent: owner(ctx), controller, subprocess, handle, sandboxPolicy, projections, disposeEffect } +} + +describe('TerminalController', () => { + it('resolves execution services from an Agent plugin context without consumer injections', async () => { + const { ctx, controller } = fixture() + let agent: Agent | undefined + await ctx.plugin((child) => { agent = owner(child) }) + if (agent === undefined) throw new Error('Agent plugin did not load') + expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace' }) + await controller.create(agent, request, signal()) + await controller.close(agent, id) + }) + + it('creates the environment default shell and keeps an existing identity when that default changes', async () => { + const { controller, agent, subprocess } = fixture({ shell: undefined }) + subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/usr/local/bin/zsh' }) + expect(controller.environment(agent, signal())).toEqual({ cwd: '/workspace', maxCols: 200, maxRows: 100, maxInputBytes: 1000, scrollback: 100 }) + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + const created = await controller.create(agent, request, signal()) + expect(created.shell.path).toBe('/usr/local/bin/zsh') + expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/usr/local/bin/zsh', '-i'] })) + subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/bin/sh' }) + expect(await controller.create(agent, request, signal())).toBe(created) + expect(subprocess.terminalEnvironment).toHaveBeenCalledOnce() + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + }) + + it('restores a running terminal after its default executable becomes unavailable', async () => { + const { controller, agent, subprocess } = fixture({ shell: undefined }) + await controller.create(agent, request, signal()) + subprocess.resolveExecutable.mockRejectedValue(new SubprocessExecutableNotFoundError('default shell was removed')) + expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace', maxInputBytes: 1000 }) + expect(controller.list(agent)).toMatchObject([{ id, state: 'running' }]) + const stream = controller.follow(agent, id, 'restored' as TerminalAttachmentId, signal())[Symbol.asyncIterator]() + try { + expect(await stream.next()).toMatchObject({ done: false, value: { type: 'snapshot', info: { id, state: 'running' } } }) + expect(subprocess.resolveExecutable).toHaveBeenCalledOnce() + await expect(controller.create(agent, { ...request, id: 'new-terminal' as WebTerminalId }, signal())).rejects.toThrow('default shell was removed') + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + } finally { await stream.return?.() } + }) + + it('honors cancellation before reading environment limits', () => { + const { controller, agent, subprocess, sandboxPolicy } = fixture() + const abort = new AbortController() + abort.abort(new Error('request cancelled')) + expect(() => controller.environment(agent, abort.signal)).toThrow('request cancelled') + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + expect(sandboxPolicy.resolve).not.toHaveBeenCalled() + }) + + it('deduplicates concurrent creates, scopes access by Session, and preserves terminals when a stream aborts', async () => { + const { controller, agent, ctx, subprocess, handle } = fixture() + const [first, second] = await Promise.all([controller.create(agent, request, signal()), controller.create(agent, request, signal())]) + expect(first.id).toBe(second.id) + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ terminalType: 'xterm-256color', cwd: '/workspace' })) + expect(controller.list(owner(ctx, 'other'))).toEqual([]) + expect(() => controller.follow(owner(ctx, 'other'), id, 'writer' as TerminalAttachmentId, signal())).toThrow('no longer exists') + const abort = new AbortController() + const stream = controller.follow(agent, id, 'writer' as TerminalAttachmentId, abort.signal)[Symbol.asyncIterator]() + const firstFrame = await stream.next() + if (firstFrame.done === true) throw new Error('Terminal stream ended before its snapshot') + expect(firstFrame.value.type).toBe('snapshot') + abort.abort() + await stream.return?.() + expect(handle.terminate).not.toHaveBeenCalled() + await controller.close(agent, id) + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + await controller.close(agent, id) + }) + + it('waits for pending allocation when explicitly closing and rolls back cancellation before publication', async () => { + const { controller, agent, subprocess, handle } = fixture() + const allocation = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => allocation.promise) + const creating = controller.create(agent, request, signal()) + const rejected = expect(creating).rejects.toThrow('closed in this Session') + const closing = controller.close(agent, id) + allocation.resolve(handle) + await rejected + await closing + expect(controller.list(agent)).toEqual([]) + expect(handle.terminate).toHaveBeenCalledOnce() + }) + + it('rejects a delayed create after close even when that identity has never been allocated', async () => { + const { controller, agent, subprocess } = fixture() + await controller.close(agent, id) + await controller.close(agent, id) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + const nextId = 'next-terminal' as WebTerminalId + await expect(controller.create(agent, { ...request, id: nextId }, signal())).resolves.toMatchObject({ id: nextId }) + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + }) + + it('rejects original, duplicate and later creates when close arrives during allocation', async () => { + const { controller, agent, subprocess, handle } = fixture() + const allocation = Promise.withResolvers() + const spawning = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise }) + const creating = controller.create(agent, request, signal()) + const duplicate = controller.create(agent, request, signal()) + const rejected = expect(creating).rejects.toThrow('closed in this Session') + const duplicateRejected = expect(duplicate).rejects.toThrow('closed in this Session') + try { + await spawning.promise + const closing = controller.close(agent, id) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + allocation.resolve(handle) + await Promise.all([rejected, duplicateRejected, closing]) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + expect(handle.terminate).toHaveBeenCalledOnce() + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + } finally { allocation.resolve(handle) } + }) + + it('retains failed allocation cleanup for a later explicit close', async () => { + const { controller, agent, subprocess, handle } = fixture() + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('request disconnected')); return handle }) + vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('cleanup failed')) + await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') + expect(controller.list(agent)).toMatchObject([{ id, state: 'failed' }]) + await controller.close(agent, id) + expect(handle.terminate).toHaveBeenCalledTimes(2) + expect(controller.list(agent)).toEqual([]) + }) + + it('retains a terminal when process cleanup fails so closing can be retried', async () => { + const { controller, agent, handle } = fixture() + await controller.create(agent, request, signal()) + vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('process still alive')) + await expect(controller.close(agent, id)).rejects.toThrow('still alive') + expect(controller.list(agent)).toHaveLength(1) + await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') + await controller.close(agent, id) + expect(controller.list(agent)).toHaveLength(0) + }) + + it('rejects invalid dimensions, unavailable shells and oversized input before executing them', async () => { + const { controller, agent, subprocess } = fixture() + await expect(controller.create(agent, { ...request, cols: 1 }, signal())).rejects.toThrow('dimensions') + subprocess.resolveExecutable.mockRejectedValueOnce(new SubprocessExecutableNotFoundError('shell unavailable')) + await expect(controller.create(agent, request, signal())).rejects.toThrow('unavailable') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + await expect(controller.write(agent, id, 'writer' as TerminalAttachmentId, 'x'.repeat(1001))).rejects.toThrow('input') + }) + + it('keeps the committed identity on repeated creation and counts pending reservations toward the Session limit', async () => { + const { controller, agent, subprocess, handle } = fixture({ maxTerminals: 1 }) + const allocation = Promise.withResolvers() + const spawning = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise }) + const creating = controller.create(agent, request, signal()) + try { + await spawning.promise + await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toThrow('limit reached') + } finally { allocation.resolve(handle) } + const first = await creating + expect(await controller.create(agent, request, signal())).toBe(first) + expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() + }) + + it('keeps failed allocations reserved until their cleanup succeeds', async () => { + const { controller, agent, subprocess, handle } = fixture({ maxTerminals: 1 }) + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort('lost request'); return handle }) + handle.terminate.mockRejectedValueOnce(new Error('still alive')) + await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') + expect(controller.list(agent)).toMatchObject([{ id, state: 'failed', error: 'lost request' }]) + await expect(controller.create(agent, request, signal())).rejects.toThrow('Close the failed terminal allocation') + await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toThrow('limit reached') + await controller.close(agent, id) + expect(controller.list(agent)).toEqual([]) + }) + + it('lets close reclaim an allocation whose request failed while close was waiting', async () => { + const { controller, agent, subprocess, handle } = fixture() + const allocation = Promise.withResolvers() + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(() => allocation.promise) + const creating = controller.create(agent, request, abort.signal) + const rejected = expect(creating).rejects.toThrow('cancelled') + const closing = controller.close(agent, id) + abort.abort(new Error('cancelled')) + allocation.resolve(handle) + await rejected + await closing + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + }) + + it('routes input, resize and trimmed names through the active attachment', async () => { + const { controller, agent, handle } = fixture({ maxInputBytes: 6 }) + await controller.create(agent, request, signal()) + const attachmentId = 'writer' as TerminalAttachmentId + const stream = controller.follow(agent, id, attachmentId, signal())[Symbol.asyncIterator]() + await stream.next() + try { + await controller.write(agent, id, attachmentId, '终端') + expect(handle.write).toHaveBeenCalledWith('终端') + await expect(controller.write(agent, id, attachmentId, '终端!')).rejects.toThrow('input exceeds') + await controller.resize(agent, id, attachmentId, 200, 100) + expect(handle.resize).toHaveBeenCalledWith(200, 100) + controller.rename(agent, id, ' server logs ') + expect(controller.list(agent)).toMatchObject([{ title: 'server logs', cols: 200, rows: 100 }]) + } finally { await stream.return?.() } + }) + + it.each([{ cols: 1.5, rows: 24 }, { cols: 201, rows: 24 }, { cols: 80, rows: 1.5 }, { cols: 80, rows: 0 }, { cols: 80, rows: 101 }])('rejects dimensions $cols × $rows before allocation or resize', async (dimensions) => { + const { controller, agent, subprocess, handle } = fixture() + await expect(controller.create(agent, { ...request, ...dimensions }, signal())).rejects.toThrow('dimensions') + await expect(controller.resize(agent, id, 'writer' as TerminalAttachmentId, dimensions.cols, dimensions.rows)).rejects.toThrow('dimensions') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + expect(handle.resize).not.toHaveBeenCalled() + }) + + it('rejects invalid wire identities and names without changing retained terminals', async () => { + const { controller, agent } = fixture() + await expect(controller.create(agent, { ...request, id: '../terminal' as WebTerminalId }, signal())).rejects.toThrow('Invalid terminal identity') + expect(() => controller.follow(agent, id, '' as TerminalAttachmentId, signal())).toThrow('attachment identity') + expect(() => { controller.rename(agent, id, ' ') }).toThrow('1–120') + expect(() => { controller.rename(agent, id, 'x'.repeat(121)) }).toThrow('1–120') + await controller.close(agent, id) + expect(controller.list(agent)).toEqual([]) + }) + + it('uses the Session sandbox policy to confine its selected shell', async () => { + const { controller, agent, ctx, sandboxPolicy, subprocess } = fixture() + const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: '/workspace', sessionId: agent.id } + sandboxPolicy.resolve.mockReturnValue(policy) + const confine = vi.fn((argv: readonly string[]) => ({ argv: ['sandbox-runner', ...argv] })) + ctx.provide('sandbox', { confine } as never) + await controller.create(agent, request, signal()) + expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy) + expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['sandbox-runner', '/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 })) + }) + + it('rejects a confined Session without a sandbox provider before spawning', async () => { + const { controller, agent, sandboxPolicy, subprocess } = fixture() + sandboxPolicy.resolve.mockReturnValue({ mode: 'read-only', workspaceRoot: '/workspace' }) + await expect(controller.create(agent, request, signal())).rejects.toThrow('requires an execution sandbox provider') + expect(subprocess.spawnTerminal).not.toHaveBeenCalled() + }) + + it.each(['subprocess', 'sandboxPolicy'] as const)('fails clearly when the Session lacks %s', (missing) => { + const { controller, subprocess, sandboxPolicy } = fixture() + const isolated = new Context() + roots.push(isolated) + if (missing !== 'subprocess') isolated.provide('subprocess', subprocess as never) + if (missing !== 'sandboxPolicy') isolated.provide('sandboxPolicy', sandboxPolicy as never) + expect(() => controller.environment(owner(isolated), signal())).toThrow('requires subprocess and sandbox policy providers') + }) + + it('blocks sandbox-mode changes only while that Session retains a terminal', async () => { + const { controller, agent, ctx, projections } = fixture() + const mode = (mode: SandboxMode): void => { ctx.emit('session/event', agent.session, { type: 'sandbox/mode', data: { mode } } as SessionEvent) } + ctx.emit('session/disposed', agent.session) + ctx.emit('session/event', agent.session, { type: 'turn/start', data: { turn: 1 } } as SessionEvent) + expect(() => { mode('workspace-write') }).not.toThrow() + await controller.create(agent, request, signal()) + expect(() => { mode('danger-full-access') }).not.toThrow() + expect(() => { mode('workspace-write') }).toThrow('Close browser terminals') + projections.stateOf.mockReturnValue('read-only') + expect(() => { mode('read-only') }).not.toThrow() + expect(() => { mode('danger-full-access') }).toThrow('Close browser terminals') + await controller.close(agent, id) + expect(() => { mode('workspace-write') }).not.toThrow() + }) + + it('terminates committed processes when the Session effect ends', async () => { + const { controller, agent, handle, disposeEffect } = fixture() + await controller.create(agent, request, signal()) + await disposeEffect('terminal-controller.owner') + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + }) + + it('cleans up a Session when its real Agent plugin fiber is disposed', async () => { + const { controller, ctx, handle } = fixture() + let agent: Agent | undefined + const fiber = await ctx.plugin((child) => { agent = owner(child) }) + if (agent === undefined) throw new Error('Agent plugin did not load') + await controller.create(agent, request, signal()) + await fiber.dispose() + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + }) + + it('waits for pending creation during Host disposal and rolls back its process before resolving', async () => { + const { controller, agent, subprocess, handle, disposeEffect } = fixture() + const allocation = Promise.withResolvers() + const spawning = Promise.withResolvers() + subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise }) + const creating = controller.create(agent, request, signal()) + const rejected = expect(creating).rejects.toThrow('disposed') + await spawning.promise + const disposing = disposeEffect('terminal-controller.processes') + allocation.resolve(handle) + await rejected + await disposing + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + await expect(controller.create(agent, request, signal())).rejects.toThrow('controller disposed') + }) + + it('shares cleanup between the Session and Host effects while awaiting process termination', async () => { + const { controller, agent, handle, disposeEffect } = fixture() + await controller.create(agent, request, signal()) + const stopping = Promise.withResolvers() + const stopped = Promise.withResolvers() + const terminate = handle.terminate.getMockImplementation() + if (terminate === undefined) throw new Error('Missing process cleanup fixture') + handle.terminate.mockImplementationOnce(async () => { stopping.resolve(undefined); await stopped.promise; await terminate() }) + const ownerDisposal = disposeEffect('terminal-controller.owner') + try { + await stopping.promise + const hostDisposal = disposeEffect('terminal-controller.processes') + stopped.resolve(undefined) + await Promise.all([ownerDisposal, hostDisposal]) + expect(handle.terminate).toHaveBeenCalledOnce() + expect(controller.list(agent)).toEqual([]) + } finally { stopped.resolve(undefined) } + }) + + it('retains cleanup failures from both Session and Host disposal for explicit retry', async () => { + const { controller, agent, handle, disposeEffect } = fixture() + await controller.create(agent, request, signal()) + handle.terminate.mockRejectedValueOnce(new Error('still alive')) + await expect(disposeEffect('terminal-controller.owner')).rejects.toThrow('Session terminal cleanup failed') + await expect(controller.create(agent, request, signal())).rejects.toThrow('Session owner disposed') + handle.terminate.mockRejectedValueOnce(new Error('still alive again')) + await expect(disposeEffect('terminal-controller.processes')).rejects.toThrow('Browser terminal cleanup failed') + expect(controller.list(agent)).toHaveLength(1) + await controller.close(agent, id) + expect(controller.list(agent)).toEqual([]) + expect(handle.terminate).toHaveBeenCalledTimes(3) + }) + + it('reclaims retained failed allocations when the owning Session ends', async () => { + const { controller, agent, subprocess, handle, disposeEffect } = fixture() + const abort = new AbortController() + subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('disconnected')); return handle }) + handle.terminate.mockRejectedValueOnce(new Error('first cleanup failed')) + await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') + await disposeEffect('terminal-controller.owner') + expect(handle.terminate).toHaveBeenCalledTimes(2) + expect(controller.list(agent)).toEqual([]) + }) +}) + +describe('shell resolution', () => { + it('accepts an omitted shell profile and defaults configured arguments to an empty list', () => { + // Loader input is unvalidated; the schema's declared type describes its normalized output. + const parse = (input: unknown): Config => TerminalController.Config(input as Config) + expect(parse({}).shell).toBeUndefined() + expect(parse({ shell: { path: 'custom', name: 'Project shell' } }).shell).toEqual({ path: 'custom', name: 'Project shell', args: [] }) + expect(() => parse({ shell: { name: 'Project shell' } })).toThrow() + }) + + it.each([ + { platform: 'posix' as const, path: '/bin/sh', name: 'sh', args: ['-i'] }, + { platform: 'windows' as const, path: 'cmd.exe', name: 'cmd.exe', args: [] }, + ])('uses the conservative $platform fallback only when the provider omits its default shell', async ({ platform, path, name, args }) => { + const { subprocess } = fixture() + subprocess.terminalEnvironment.mockResolvedValue({ platform }) + const runtime = subprocess as unknown as SubprocessRuntime + const requestSignal = signal() + expect(await resolveShell(runtime, undefined, requestSignal)).toEqual({ path, name, args }) + expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith(path, undefined, requestSignal) + }) + + it.each([ + { platform: 'posix' as const, defaultShell: '/usr/local/bin/fish', name: 'fish', args: ['-i'] }, + { platform: 'windows' as const, defaultShell: 'C:\\Windows\\CMD.EXE', name: 'CMD.EXE', args: [] }, + { platform: 'windows' as const, defaultShell: 'C:\\PowerShell\\powershell.exe', name: 'powershell.exe', args: ['-NoLogo'] }, + { platform: 'posix' as const, defaultShell: '/usr/local/bin/pwsh', name: 'pwsh', args: ['-NoLogo'] }, + ])('resolves only the declared default $defaultShell with its interactive arguments', async ({ platform, defaultShell, name, args }) => { + const { subprocess } = fixture() + subprocess.terminalEnvironment.mockResolvedValue({ platform, defaultShell }) + subprocess.resolveExecutable.mockResolvedValue('/resolved/shell') + const runtime = subprocess as unknown as SubprocessRuntime + const requestSignal = signal() + expect(await resolveShell(runtime, undefined, requestSignal)).toEqual({ path: '/resolved/shell', name, args }) + expect(subprocess.terminalEnvironment).toHaveBeenCalledExactlyOnceWith(requestSignal) + expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith(defaultShell, undefined, requestSignal) + }) + + it('uses an explicit profile without querying the environment and preserves its name and arguments', async () => { + const { subprocess } = fixture() + subprocess.resolveExecutable.mockResolvedValue('/resolved/custom') + const runtime = subprocess as unknown as SubprocessRuntime + const requestSignal = signal() + const configured = { path: 'custom', name: 'Project shell', args: ['--project'] } + expect(await resolveShell(runtime, configured, requestSignal)).toEqual({ ...configured, path: '/resolved/custom' }) + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith('custom', undefined, requestSignal) + }) + + it.each([new SubprocessExecutableNotFoundError('missing default'), new Error('transport unavailable')])('reports default resolution failure without attempting fallback: %s', async (failure) => { + const { subprocess } = fixture() + subprocess.resolveExecutable.mockRejectedValue(failure) + const runtime = subprocess as unknown as SubprocessRuntime + await expect(resolveShell(runtime, undefined, signal())).rejects.toBe(failure) + expect(subprocess.resolveExecutable).toHaveBeenCalledOnce() + }) + + it('reports an unavailable configured shell without consulting the provider default', async () => { + const { subprocess } = fixture() + const failure = new SubprocessExecutableNotFoundError('missing configured shell') + subprocess.resolveExecutable.mockRejectedValue(failure) + const runtime = subprocess as unknown as SubprocessRuntime + await expect(resolveShell(runtime, { path: '/missing', name: 'missing', args: [] }, signal())).rejects.toBe(failure) + expect(subprocess.resolveExecutable).toHaveBeenCalledOnce() + expect(subprocess.terminalEnvironment).not.toHaveBeenCalled() + }) + + it('propagates environment cancellation before verifying an executable', async () => { + const { subprocess } = fixture() + const cancelled = new Error('request disconnected') + subprocess.terminalEnvironment.mockRejectedValue(cancelled) + const runtime = subprocess as unknown as SubprocessRuntime + await expect(resolveShell(runtime, undefined, signal())).rejects.toBe(cancelled) + expect(subprocess.resolveExecutable).not.toHaveBeenCalled() + }) +}) + +it.skipIf(process.platform === 'win32')('runs a real interactive shell with completion, TERM and live window dimensions', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-web-terminal-')) + const ctx = new Context() + const runtime = await ctx.plugin(LocalSubprocessRuntime) + try { + const handle = await ctx.subprocess.spawnTerminal({ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd, rows: 24, cols: 80, terminalType: 'xterm-256color', graceMs: 100, env: { PS1: 'READY> ', PS2: '' } }) + let output = '' + handle.output.on('data', (data: Buffer) => { output += data.toString('utf8') }) + try { + await expect.poll(() => output).toContain('READY>') + await handle.resize(100, 30) + await handle.write("printf 'TERM:%s\\n' \"$TERM\"; stty size\r") + await expect.poll(() => output).toContain('TERM:xterm-256color') + await expect.poll(() => output).toContain('30 100') + // Bash's builtin completion expands this unambiguous command before Enter. + await handle.write('histor\t') + await expect.poll(() => output).toContain('history') + } finally { await handle.terminate() } + await expect(handle.done).resolves.toBeDefined() + } finally { + await runtime.dispose() + await ctx.fiber.dispose() + await rm(cwd, { recursive: true, force: true }) + } +}) diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts new file mode 100644 index 0000000000..24be9f983d --- /dev/null +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -0,0 +1,503 @@ +/** Browser view ownership across slow RPCs, remounts and transport generations. */ +import { afterEach, expect, it, vi } from 'vitest' +import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { TerminalView, type TerminalRemote } from '../src/client/model.ts' +import type { TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' + +const sessionId = 'session' as SessionId +const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'bash', path: '/bin/bash', args: ['-i'] }, title: 'bash', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null } +const environment: TerminalEnvironment = { + cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100, +} +const success = (value: T): RemoteResult => ({ ok: true, value }) +const cleanups: (() => void | Promise)[] = [] +afterEach(async () => { for (const close of cleanups.splice(0).reverse()) await close() }) + +function untilAborted(signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) resolve() + else signal?.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +function failure(message: string): RemoteResult { + return { ok: false, error: new RemoteError('gateway/bad-request', message, {}) } +} + +function fixture(prepareStream?: (stream: RemoteStream) => void) { + const remote: TerminalRemote = { + environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), + create: vi.fn(async (_sessionId, request) => success({ ...info, id: request.id })), + close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), + write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), + follow: vi.fn(async function* (_sessionId, id, attachmentId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id, controllerId: attachmentId } } + await untilAborted(signal) + }), + } + const options: RemoteStreamOptions[] = [] + const streams: { dispose(): Promise; restart(): void }[] = [] + const generation = createSnapshotStore[0]['generation']['getSnapshot']>>(undefined) + const gateway: Pick = { + $stream: (option) => { + options.push(option) + const stream = new RemoteStream({ generation }, option) + streams.push(stream) + prepareStream?.(stream) + return stream + }, + } + cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) }) + const model = new TerminalView(sessionId, remote, gateway, info.id) + cleanups.push(() => { model.dispose() }) + return { model, remote, gateway, options, streams, generation } +} + +async function mount(model: TerminalView) { + const detach = model.mount() + await model.refresh() + return detach +} + +function acknowledge(model: TerminalView): void { + const render = model.state.getSnapshot().render + if (render !== undefined) model.acknowledge(render.revision) +} + +async function connected(model: TerminalView): Promise { + await mount(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + acknowledge(model) +} + +it('does not let a late input failure downgrade a newer connection', async () => { + const { model, remote } = fixture() + await mount(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + acknowledge(model) + const input = Promise.withResolvers>() + vi.mocked(remote.write).mockReturnValueOnce(input.promise) + model.write('x') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + model.connect() + await expect.poll(() => vi.mocked(remote.follow).mock.calls.length).toBe(2) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.write('current input') + input.reject(new Error('old attachment was replaced')) + await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'current input')).toBe(true) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) +}) + +it('waits for render acknowledgement before publishing subsequent output', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + const frames: TerminalFrame[] = [ + { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } }, + { type: 'output', sequence: 1, data: 'first' }, + { type: 'output', sequence: 2, data: 'second' }, + ] + for (const frame of frames) yield frame + await untilAborted(signal) + }) + await mount(model) + await expect.poll(() => model.state.getSnapshot().render?.frame.type).toBe('snapshot') + acknowledge(model) + await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 1 }) + acknowledge(model) + await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 2 }) +}) + +it('serializes input and resizes, clamps geometry, and suppresses unchanged sizes', async () => { + const { model, remote } = fixture() + await connected(model) + const input = Promise.withResolvers>() + vi.mocked(remote.write).mockReturnValueOnce(input.promise) + model.resize(80, 24) + model.write('first') + model.write('second') + model.resize(500, 300) + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + expect(remote.resize).not.toHaveBeenCalled() + input.resolve(success(undefined)) + await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1) + const attachmentId = vi.mocked(remote.follow).mock.calls[0]![2] + const id = model.state.getSnapshot().info!.id + expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['first', 'second']) + expect(remote.resize).toHaveBeenCalledWith(sessionId, id, attachmentId, 200, 100) + model.resize(90, 24) + await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(2) +}) + +it('bounds queued input by UTF-8 bytes and releases the byte budget after settlement', async () => { + const { model, remote } = fixture() + vi.mocked(remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 6 })) + await connected(model) + const input = Promise.withResolvers>() + vi.mocked(remote.write).mockReturnValueOnce(input.promise) + model.write('界界') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + model.write('a') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'Terminal input buffer is full' }) + input.resolve(success(undefined)) + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.write('界界') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(2) + expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['界界', '界界']) +}) + +it('drops queued commands from a detached attachment and ignores its late resize failure', async () => { + const { model, remote } = fixture() + await connected(model) + const input = Promise.withResolvers>() + vi.mocked(remote.resize).mockReturnValueOnce(input.promise) + model.resize(100, 30) + await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1) + model.write('stale') + model.resize(110, 35) + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.write('current') + input.resolve(failure('old attachment')) + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + expect(vi.mocked(remote.write).mock.calls[0]?.[3]).toBe('current') + expect(remote.resize).toHaveBeenCalledOnce() + expect(model.state.getSnapshot().phase).toBe('connected') +}) + +it('publishes current input and resize failures and can reconnect for another attempt', async () => { + const { model, remote } = fixture() + await connected(model) + vi.mocked(remote.write).mockResolvedValueOnce(failure('input refused')) + model.write('x') + await expect.poll(() => model.state.getSnapshot().error).toBe('input refused') + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + vi.mocked(remote.resize).mockResolvedValueOnce(failure('resize refused')) + model.resize(100, 30) + await expect.poll(() => model.state.getSnapshot().error).toBe('resize refused') +}) + +it('treats carrier loss as disconnected only for the active attachment', async () => { + const { model, options } = fixture() + await connected(model) + const first = options[0]! + first.carrierFailed?.(new RemoteStreamCarrierError('offline')) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', writable: false }) + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + first.carrierFailed?.(new RemoteStreamCarrierError('late old failure')) + expect(model.state.getSnapshot().phase).toBe('connected') +}) + +it.each([ + ['output before snapshot', [{ type: 'output', sequence: 1, data: 'orphan' }], 'missing its screen snapshot'], + ['sequence gap', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'output', sequence: 6, data: 'gap' }], 'sequence has a gap'], + ['repeated snapshot', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'snapshot', sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'], +] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* () { yield* frames }) + await mount(model) + if (frames[0]?.type === 'snapshot') { + await expect.poll(() => model.state.getSnapshot().render).toBeDefined() + acknowledge(model) + } + await expect.poll(() => model.state.getSnapshot().error).toContain(message) + expect(model.state.getSnapshot().writable).toBe(false) +}) + +it('retains an exited screen and closes controls when its stream ends', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId) { + yield { type: 'snapshot', sequence: 0, screen: 'last screen', info: { ...info, id, controllerId } } + yield { type: 'state', info: { ...info, id, controllerId, state: 'exited', exitCode: 3 } } + }) + await connected(model) + await expect.poll(() => model.state.getSnapshot().phase).toBe('closed') + expect(model.state.getSnapshot()).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 3 }, render: { frame: { screen: 'last screen' } } }) +}) + +it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId) { + yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } } + }) + await connected(model) + await expect.poll(() => model.state.getSnapshot().error).toContain('Terminal attachment ended') + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) +}) + +it('publishes remote control transfer without replacing the retained screen', async () => { + const { model, remote } = fixture() + vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } + yield { type: 'state', info: { ...info, id } } + await untilAborted(signal) + }) + await connected(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(false) + expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) +}) + +/** Pause delivery after the real Gateway iterator settles, before the model observes it. */ +function deliveryBarrier() { + const ready = Promise.withResolvers() + const release = Promise.withResolvers() + let held = false + function prepare(stream: RemoteStream): void { + if (held) return + held = true + const iterator = stream[Symbol.asyncIterator]() + let first = true + vi.spyOn(stream, Symbol.asyncIterator).mockReturnValue({ + async next() { + if (!first) return iterator.next() + first = false + try { + return await iterator.next() + } finally { + ready.resolve(undefined) + await release.promise + } + }, + async return() { return iterator.return!() }, + }) + } + cleanups.push(() => { release.resolve(undefined) }) + return { ready: ready.promise, release: () => { release.resolve(undefined) }, prepare } +} + +it('ignores an already delivered screen when its attachment is replaced before the model receives it', async () => { + const barrier = deliveryBarrier() + const { model, remote } = fixture(barrier.prepare) + await mount(model) + await barrier.ready + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + const current = model.state.getSnapshot() + barrier.release() + await model.rename('settled') + expect(model.state.getSnapshot().render).toBe(current.render) + expect(remote.follow).toHaveBeenCalledTimes(2) +}) + +it('ignores an already settled stream error after a newer attachment has become writable', async () => { + const barrier = deliveryBarrier() + const { model, remote } = fixture(barrier.prepare) + vi.mocked(remote.follow).mockImplementationOnce(async function* () { + yield* [] + throw new Error('retired attachment failed') + }) + await mount(model) + await barrier.ready + model.connect() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + barrier.release() + await model.rename('settled') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) +}) + +it('releases a pending screen render on carrier generation cancellation and accepts the replacement screen', async () => { + const { model, remote, streams } = fixture() + await mount(model) + await expect.poll(() => model.state.getSnapshot().render).toBeDefined() + const revision = model.state.getSnapshot().render!.revision + streams[0]!.restart() + await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(revision + 1) + expect(remote.follow).toHaveBeenCalledTimes(2) + expect(model.state.getSnapshot().writable).toBe(true) +}) + +it('does not wait for the DOM callback of a screen whose generation was already cancelled at delivery', async () => { + const barrier = deliveryBarrier() + const { model, remote, streams } = fixture(barrier.prepare) + await mount(model) + await barrier.ready + streams[0]!.restart() + barrier.release() + await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(2) + expect(remote.follow).toHaveBeenCalledTimes(2) + expect(model.state.getSnapshot().writable).toBe(true) +}) + +it.each(['write', 'resize'] as const)('keeps a fresh attachment writable when a pending %s fails after automatic transport recovery', async (operation) => { + const { model, remote, generation } = fixture() + generation.set({ id: 1, host: { home: '/home/fixture' } }) + const disconnected = Promise.withResolvers() + vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'before disconnect', info: { ...info, id, controllerId } } + await Promise.race([disconnected.promise, untilAborted(signal)]) + if (signal?.aborted) return + throw new RemoteStreamCarrierError('connection lost') + }) + await connected(model) + const pending = Promise.withResolvers>() + if (operation === 'write') { + vi.mocked(remote.write).mockReturnValueOnce(pending.promise) + model.write('old input') + } else { + vi.mocked(remote.resize).mockReturnValueOnce(pending.promise) + model.resize(100, 30) + } + await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1) + const oldAttachmentId = vi.mocked(remote.follow).mock.calls[0]![2] + generation.set(undefined) + disconnected.resolve(undefined) + await expect.poll(() => model.state.getSnapshot().phase).toBe('disconnected') + expect(model.state.getSnapshot().writable).toBe(false) + expect(remote.follow).toHaveBeenCalledOnce() + + generation.set({ id: 2, host: { home: '/home/fixture' } }) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + expect(remote.follow).toHaveBeenCalledTimes(2) + const attachmentId = vi.mocked(remote.follow).mock.calls[1]![2] + expect(attachmentId).not.toBe(oldAttachmentId) + expect(model.state.getSnapshot().info?.controllerId).toBe(attachmentId) + expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'ready' }) + + model.write('fresh input') + pending.reject(new Error('old attachment was replaced')) + await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'fresh input')).toBe(true) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined }) + expect(vi.mocked(remote.write).mock.calls.at(-1)?.[2]).toBe(attachmentId) + expect(remote.create).toHaveBeenCalledOnce() + expect(remote.close).not.toHaveBeenCalled() +}) + +it('ignores controls before discovery and reconnects an existing process after remount', async () => { + const { model, remote } = fixture() + model.connect() + model.write('early') + model.resize(100, 30) + model.acknowledge(100) + expect(remote.follow).not.toHaveBeenCalled() + expect(remote.write).not.toHaveBeenCalled() + expect(remote.resize).not.toHaveBeenCalled() + const unmount = await mount(model) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + unmount() + model.connect() + model.write('detached') + model.resize(100, 30) + expect(remote.follow).toHaveBeenCalledOnce() + expect(remote.write).not.toHaveBeenCalled() + expect(remote.resize).not.toHaveBeenCalled() + model.mount() + await expect.poll(() => remote.follow).toHaveBeenCalledTimes(2) + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + expect(remote.create).toHaveBeenCalledOnce() +}) + +it('keeps controls inert when process metadata or the attachment is unavailable', async () => { + const { model, remote } = fixture() + await connected(model) + const state = model.state.getSnapshot() + model.state.set({ ...state, info: undefined }) + model.write('missing metadata') + model.resize(100, 30) + expect(remote.write).not.toHaveBeenCalled() + expect(remote.resize).not.toHaveBeenCalled() + model.state.set({ ...state, environment: undefined }) + model.resize(100, 30) + await expect.poll(() => remote.resize).toHaveBeenCalledOnce() + expect(vi.mocked(remote.resize).mock.calls[0]?.slice(-2)).toEqual([100, 30]) + model.write('unknown budget') + expect(model.state.getSnapshot().error).toBe('Terminal input buffer is full') + expect(remote.write).not.toHaveBeenCalled() +}) + +it('renames an existing terminal, skips unchanged names, and exposes rename failures', async () => { + const { model, remote } = fixture() + await connected(model) + await model.rename(` ${info.title} `) + expect(remote.rename).not.toHaveBeenCalled() + await model.rename(' Build ') + expect(model.state.getSnapshot()).toMatchObject({ title: 'Build', info: { title: 'Build' } }) + vi.mocked(remote.rename).mockRejectedValueOnce('rename connection lost') + await model.rename('Other') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'rename connection lost', title: 'Build' }) + model.dispose() + await model.rename('Ignored') + expect(remote.rename).toHaveBeenCalledTimes(2) +}) + +it('classifies a discovery carrier failure as disconnected and supports explicit retry', async () => { + const { model, remote } = fixture() + vi.mocked(remote.environment).mockRejectedValueOnce(new RemoteStreamCarrierError('offline')) + await model.refresh() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', error: 'offline' }) + expect(remote.create).not.toHaveBeenCalled() + await model.refresh() + expect(model.state.getSnapshot().info?.id).toBe(info.id) + expect(remote.create).toHaveBeenCalledOnce() +}) + +it('retains state when disposal overtakes successful allocation or a failed discovery', async () => { + for (const operation of ['creation', 'discovery'] as const) { + const { model, remote } = fixture() + const creation = Promise.withResolvers>() + const discovery = Promise.withResolvers>() + if (operation === 'creation') vi.mocked(remote.create).mockReturnValueOnce(creation.promise) + else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise) + const loading = model.refresh() + if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce() + model.dispose() + const before = model.state.getSnapshot() + if (operation === 'creation') creation.resolve(success(info)) + else discovery.reject(new Error('late discovery failure')) + await loading + expect(model.state.getSnapshot()).toBe(before) + expect(remote.follow).not.toHaveBeenCalled() + } +}) + +it('ignores a successful rename after the view is disposed', async () => { + const { model, remote } = fixture() + await connected(model) + const rename = Promise.withResolvers>() + vi.mocked(remote.rename).mockReturnValueOnce(rename.promise) + const pending = model.rename('Late') + model.dispose() + const before = model.state.getSnapshot() + rename.resolve(success(undefined)) + await pending + expect(model.state.getSnapshot()).toBe(before) +}) + +it('allows retry after failed process cleanup and never reconnects while close is pending', async () => { + const { model, remote } = fixture() + await connected(model) + const closing = Promise.withResolvers>() + vi.mocked(remote.close).mockReturnValueOnce(closing.promise) + const first = model.close() + expect(model.close()).toBe(first) + model.connect() + expect(remote.follow).toHaveBeenCalledOnce() + closing.resolve(failure('close refused')) + await expect(first).rejects.toThrow('close refused') + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'close refused' }) + await model.close() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'closed', writable: false }) + expect(remote.close).toHaveBeenCalledTimes(2) +}) + +it('does not publish a close result or reconnect after disposal', async () => { + const { model, remote } = fixture() + await connected(model) + const closing = Promise.withResolvers>() + vi.mocked(remote.close).mockReturnValueOnce(closing.promise) + const pending = model.close() + model.dispose() + const before = model.state.getSnapshot() + closing.resolve(success(undefined)) + await pending + model.mount() + model.connect() + expect(model.state.getSnapshot()).toBe(before) + expect(remote.follow).toHaveBeenCalledOnce() +}) diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts new file mode 100644 index 0000000000..f9c2438fd2 --- /dev/null +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -0,0 +1,369 @@ +/** Reloaded tab identities and nonblocking close requests use independent lifetimes. */ +import { afterEach, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WebTerminalId, WebTerminalInfo, TerminalEnvironment } from '../src/types.ts' +import { TerminalView, type TerminalRemote } from '../src/client/model.ts' +import { TerminalCloseRequests } from '../src/client/close-requests.ts' +import * as TerminalClient from '../src/client/index.ts' + +const sessionId = 'session' as SessionId +const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'zsh', path: '/bin/zsh', args: ['-i'] }, title: 'zsh', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null } +const environment: TerminalEnvironment = { cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100 } +const success = (value: T): RemoteResult => ({ ok: true, value }) +const failure = (message: string): RemoteResult => ({ ok: false, error: new RemoteError('gateway/bad-request', message, {}) }) +const cleanups: (() => void | Promise)[] = [] +afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() }) + +function storage() { + const data = new Map() + vi.stubGlobal('localStorage', { get length() { return data.size }, key: (index: number) => [...data.keys()][index] ?? null, getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value) }, removeItem: (key: string) => { data.delete(key) } }) + return data +} + +function fixture() { + const remote: TerminalRemote = { + environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), + create: vi.fn(async (_session, request) => success({ ...info, id: request.id })), + close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), + write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), + follow: vi.fn(async function* (_session, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } + await new Promise((resolve) => { + if (signal?.aborted) resolve() + else signal?.addEventListener('abort', () => { resolve() }, { once: true }) + }) + }), + } + const gateway: Pick = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) } + function view() { + const model = new TerminalView(sessionId, remote, gateway, info.id) + cleanups.push(() => { model.dispose() }) + return model + } + async function service() { + const ctx = new Context() + ctx.provide('remote', { ...gateway, terminal: remote } as never) + ctx.provide('remote.terminal', remote) + const fiber = ctx.plugin(TerminalClient) + cleanups.push(async () => { await ctx.fiber.dispose() }) + await fiber + return { service: ctx.webTerminals, dispose: () => fiber.dispose() } + } + return { remote, view, service } +} + +it('starts the default shell once across overlapping mounts and retries, with no shell choice in the request', async () => { + const h = fixture() + const model = h.view() + const creation = Promise.withResolvers>() + vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise) + model.mount() + const loading = model.refresh() + expect(model.refresh()).toBe(loading) + await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1) + expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, cols: 80, rows: 24 }) + creation.resolve(success(info)) + await loading + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('recovers a listed process without creating another, including its title and screen', async () => { + const h = fixture() + vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, title: 'Build' }])) + const model = h.view() + model.mount() + await model.refresh() + expect(h.remote.create).not.toHaveBeenCalled() + await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) + expect(h.remote.follow).toHaveBeenCalledWith(sessionId, info.id, expect.any(String), expect.any(AbortSignal)) +}) + +it('does not recreate a recovered terminal that disappeared after the recovery list was shown', async () => { + const h = fixture() + const { service } = await h.service() + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + expect(await service.recover(sessionId)).toEqual([info]) + const model = service.view(sessionId, 'recovered', info.id) + await model.refresh() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', writable: false }) + expect(model.state.getSnapshot().error).toBeDefined() + expect(h.remote.create).not.toHaveBeenCalled() + model.mount() + await model.refresh() + expect(h.remote.create).not.toHaveBeenCalled() +}) + +it('retries lost create acknowledgements with the saved id and closes after a refused allocation', async () => { + const h = fixture() + const model = h.view() + vi.mocked(h.remote.create).mockResolvedValueOnce(failure('response lost')) + await model.refresh() + expect(model.state.getSnapshot().error).toBe('response lost') + await model.refresh() + expect(vi.mocked(h.remote.create).mock.calls.map(call => call[1].id)).toEqual([info.id, info.id]) + await model.close() + expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) +}) + +it('waits for an in-flight creation while close detaches immediately and prevents a late connection', async () => { + const h = fixture() + const model = h.view() + const creation = Promise.withResolvers>() + vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise) + const unmount = model.mount() + await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1) + const closing = model.close() + expect(model.close()).toBe(closing) + unmount() + expect(h.remote.close).not.toHaveBeenCalled() + creation.resolve(failure('cancelled allocation')) + await closing + expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) + expect(h.remote.follow).not.toHaveBeenCalled() +}) + +it('does not allocate after close or disposal overtakes discovery', async () => { + for (const action of ['close', 'dispose'] as const) { + const h = fixture() + const model = h.view() + const discovery = Promise.withResolvers>() + vi.mocked(h.remote.environment).mockReturnValueOnce(discovery.promise) + const loading = model.refresh() + await model[action]() + discovery.resolve(success(environment)) + await loading + await model.refresh() + expect(h.remote.create).not.toHaveBeenCalled() + } +}) + +it('updates a restored inactive terminal title and ignores late results after disposal', async () => { + const h = fixture() + const model = h.view() + await model.rename(' Build ') + expect(h.remote.rename).toHaveBeenCalledWith(sessionId, info.id, ' Build ') + expect(model.state.getSnapshot().title).toBe('Build') + const rename = Promise.withResolvers>() + vi.mocked(h.remote.rename).mockReturnValueOnce(rename.promise) + const pending = model.rename('Late') + model.dispose() + rename.resolve(success(undefined)) + await pending + expect(model.state.getSnapshot().title).toBe('Build') +}) + +it('removes a view synchronously, keeps cleanup retryable, and never deletes a replacement view', async () => { + storage() + const h = fixture() + const { service } = await h.service() + const first = service.view(sessionId, 'tab') + first.mount() + await first.refresh() + const closing = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(closing.promise) + service.close(sessionId, 'tab') + const replacement = service.view(sessionId, 'tab') + expect(replacement).not.toBe(first) + expect(service.closeFailures.getSnapshot()).toEqual([]) + closing.resolve(failure('termination refused')) + await expect.poll(() => service.closeFailures.getSnapshot().length).toBe(1) + const failed = service.closeFailures.getSnapshot()[0]! + service.retryClose(failed.id) + service.retryClose(failed.id) + await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([]) + expect(service.view(sessionId, 'tab')).toBe(replacement) + await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([]) +}) + +it('closes an inactive restored tab by tab identity and retries saved close requests after reload', async () => { + storage() + const h = fixture() + const first = await h.service() + first.service.close(sessionId, 'inactive', 'inactive' as WebTerminalId) + await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1) + expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'inactive') + await first.dispose() + const pending = new TerminalCloseRequests() + pending.save({ sessionId, id: 'second' as WebTerminalId, title: 'Second' }) + await h.service() + await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(2) + expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'second') + await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([]) +}) + +it('keeps concurrent windows close requests independent and removes only the settled request', () => { + storage() + const a = new TerminalCloseRequests() + const b = new TerminalCloseRequests() + a.save({ sessionId, id: 'a' as WebTerminalId, title: 'A' }) + b.save({ sessionId, id: 'b' as WebTerminalId, title: 'B' }) + expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['a', 'b']) + a.remove('a' as WebTerminalId) + expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['b']) +}) + +it('recovers Host terminals by Session while excluding held, pending-close, and already-closed identities', async () => { + storage() + const h = fixture() + const { service } = await h.service() + const held = service.view(sessionId, 'held') + await held.refresh() + const closing = service.view(sessionId, 'closing') + await closing.refresh() + const done = service.view(sessionId, 'closed') + await done.refresh() + const unheld: WebTerminalInfo = { ...info, id: 'unheld' as WebTerminalId } + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.close(sessionId, 'closing') + service.close(sessionId, 'closed') + await expect.poll(() => new TerminalCloseRequests().pending().length).toBe(1) + vi.mocked(h.remote.list).mockResolvedValue(success([ + { ...info, id: held.id }, { ...info, id: closing.id }, { ...info, id: done.id }, unheld, + ])) + expect(await service.recover(sessionId)).toEqual([unheld]) + expect(h.remote.list).toHaveBeenLastCalledWith(sessionId) + const otherSession = 'other-session' as SessionId + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + expect(await service.recover(otherSession)).toEqual([info]) + pending.resolve(success(undefined)) + await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([]) + expect(await service.recover(sessionId)).toEqual([unheld]) +}) + +it('queries current held views after a slow recovery response and reports discovery errors', async () => { + const h = fixture() + const { service } = await h.service() + const listed = Promise.withResolvers>() + vi.mocked(h.remote.list).mockReturnValueOnce(listed.promise) + const recovering = service.recover(sessionId) + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + const model = service.view(sessionId, 'recovered', info.id) + await model.refresh() + listed.resolve(success([info])) + expect(await recovering).toEqual([]) + expect(service.view(sessionId, 'recovered', info.id)).toBe(model) + expect(model.id).toBe(info.id) + vi.mocked(h.remote.list).mockResolvedValueOnce(failure('Session unavailable')) + await expect(service.recover(sessionId)).rejects.toThrow('Session unavailable') +}) + +it('ignores an unknown tab and retries only saved close requests', async () => { + storage() + const h = fixture() + const { service } = await h.service() + service.close(sessionId, 'unknown') + service.retryClose('unknown' as WebTerminalId) + expect(h.remote.close).not.toHaveBeenCalled() + expect(new TerminalCloseRequests().pending()).toEqual([]) +}) + +it('retains an inactive close failure with its tab title until retry succeeds', async () => { + const data = storage() + const h = fixture() + const { service } = await h.service() + vi.mocked(h.remote.close).mockResolvedValueOnce(failure('Host refused cleanup')) + service.close(sessionId, 'Build', info.id) + expect(data.has(`dsh.terminal.close.v1.${info.id}`)).toBe(true) + await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'Host refused cleanup' }]) + vi.mocked(h.remote.list).mockResolvedValueOnce(success([info])) + expect(await service.recover(sessionId)).toEqual([]) + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.retryClose(info.id) + service.retryClose(info.id) + expect(service.closeFailures.getSnapshot()).toEqual([]) + expect(h.remote.close).toHaveBeenCalledTimes(2) + pending.resolve(success(undefined)) + await expect.poll(() => data.size).toBe(0) +}) + +it('preserves a close failure from a non-Error rejection', async () => { + storage() + const h = fixture() + const { service } = await h.service() + vi.mocked(h.remote.close).mockRejectedValueOnce('carrier closed') + service.close(sessionId, 'Build', info.id) + await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'carrier closed' }]) +}) + +it('waits for pending cleanup during service disposal without publishing a late failure', async () => { + storage() + const h = fixture() + const { service, dispose } = await h.service() + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.close(sessionId, 'Build', info.id) + let finished = false + const disposing = dispose().then(() => { finished = true }) + expect(finished).toBe(false) + pending.resolve(failure('transport is stopping')) + await disposing + expect(service.closeFailures.getSnapshot()).toEqual([]) + expect(new TerminalCloseRequests().pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }]) + service.retryClose(info.id) + expect(h.remote.close).toHaveBeenCalledOnce() +}) + +it('saves close intents without persisting any active terminal or sidebar state', async () => { + const data = storage() + const h = fixture() + const { service } = await h.service() + const model = service.view(sessionId, 'new-tab') + await model.refresh() + expect(model.id).toMatch(/^[0-9a-f-]{36}$/) + expect(data.size).toBe(0) + const pending = Promise.withResolvers>() + vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) + service.close(sessionId, 'new-tab') + const request = { sessionId, id: model.id, title: info.title } + expect([...data.entries()]).toEqual([[`dsh.terminal.close.v1.${model.id}`, JSON.stringify(request)]]) + pending.resolve(success(undefined)) + await expect.poll(() => data.size).toBe(0) +}) + +it.each(['{broken', 'null', '{}', '[{}]', '{"sessionId":"s","id":"bad/id","title":"x"}', '{"sessionId":"s","id":"different","title":"x"}'])('discards malformed saved cleanup: %s', (raw) => { + const data = storage() + data.set('dsh.terminal.close.v1.terminal', raw) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(new TerminalCloseRequests().pending()).toEqual([]) + expect(error).toHaveBeenCalledOnce() +}) + +it('skips unrelated storage and cleanup keys removed during enumeration', () => { + const getItem = vi.fn(() => null) + vi.stubGlobal('localStorage', { + length: 3, + key: (index: number) => ['unrelated', 'dsh.terminal.close.v1.gone', null][index], + getItem, + }) + expect(new TerminalCloseRequests().pending()).toEqual([]) + expect(getItem).toHaveBeenCalledExactlyOnceWith('dsh.terminal.close.v1.gone') +}) + +it('keeps close requests usable without browser storage', () => { + vi.stubGlobal('localStorage', undefined) + const requests = new TerminalCloseRequests() + requests.save({ sessionId, id: info.id, title: 'Build' }) + expect(requests.pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }]) + requests.remove(info.id) + expect(requests.pending()).toEqual([]) +}) + +it('keeps cleanup usable in memory when storage access itself is denied', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('localStorage', undefined) + Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('storage denied') } }) + const requests = new TerminalCloseRequests() + requests.save({ sessionId, id: info.id, title: 'Build' }) + expect(requests.pending()).toHaveLength(1) + requests.remove(info.id) + expect(requests.pending()).toEqual([]) + expect(error).toHaveBeenCalledTimes(3) +}) diff --git a/packages/api/terminal-controller/tests/stream.spec.ts b/packages/api/terminal-controller/tests/stream.spec.ts new file mode 100644 index 0000000000..a5fd9357ff --- /dev/null +++ b/packages/api/terminal-controller/tests/stream.spec.ts @@ -0,0 +1,80 @@ +/** Output followers drain on process exit and discard queued data on detach. */ +import { describe, expect, it } from 'vitest' +import { TerminalFollower } from '../src/stream.ts' +import type { TerminalFrame } from '../src/types.ts' + +const frame: TerminalFrame = { type: 'output', sequence: 1, data: '终端' } +const signal = (): AbortSignal => new AbortController().signal + +describe('TerminalFollower', () => { + it('accepts the exact byte limit and restores capacity after each frame is consumed', async () => { + const follower = new TerminalFollower(Buffer.byteLength(JSON.stringify(frame), 'utf8')) + const stream = follower.read(signal())[Symbol.asyncIterator]() + try { + follower.push(frame) + expect(await stream.next()).toEqual({ done: false, value: frame }) + follower.push(frame) + follower.finish() + follower.push({ ...frame, sequence: 2 }) + expect(await stream.next()).toEqual({ done: false, value: frame }) + expect(await stream.next()).toEqual({ done: true, value: undefined }) + } finally { follower.close(); await stream.return?.() } + }) + + it('fails accumulated overflow using encoded byte size', async () => { + const follower = new TerminalFollower(Buffer.byteLength(JSON.stringify(frame), 'utf8')) + follower.push(frame) + follower.push(frame) + await expect(follower.read(signal())[Symbol.asyncIterator]().next()).rejects.toThrow('reconnect to recover') + }) + + it('discards queued data when detached and ignores later output', async () => { + const follower = new TerminalFollower(1000) + follower.push(frame) + follower.close() + follower.push(frame) + expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) + + it('does not deliver buffered output to an already-aborted request', async () => { + const follower = new TerminalFollower(1000) + follower.push(frame) + const abort = new AbortController() + abort.abort() + expect(await follower.read(abort.signal)[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) + + it('wakes a waiting reader for output and then for graceful completion', async () => { + const follower = new TerminalFollower(1000) + const stream = follower.read(signal())[Symbol.asyncIterator]() + try { + const reading = stream.next() + follower.push(frame) + expect(await reading).toEqual({ done: false, value: frame }) + const finishing = stream.next() + follower.finish() + expect(await finishing).toEqual({ done: true, value: undefined }) + } finally { follower.close(); await stream.return?.() } + }) + + it('wakes a waiting reader on cancellation', async () => { + const follower = new TerminalFollower(1000) + const abort = new AbortController() + const stream = follower.read(abort.signal)[Symbol.asyncIterator]() + try { + const reading = stream.next() + abort.abort() + expect(await reading).toEqual({ done: true, value: undefined }) + } finally { follower.close(); await stream.return?.() } + }) + + it('closes the follower when its consumer returns before EOF', async () => { + const follower = new TerminalFollower(1000) + const stream = follower.read(signal())[Symbol.asyncIterator]() + follower.push(frame) + await stream.next() + await stream.return?.() + follower.push(frame) + expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined }) + }) +}) diff --git a/packages/api/terminal-controller/tests/terminal.spec.ts b/packages/api/terminal-controller/tests/terminal.spec.ts new file mode 100644 index 0000000000..86c229e158 --- /dev/null +++ b/packages/api/terminal-controller/tests/terminal.spec.ts @@ -0,0 +1,220 @@ +/** Terminal screen continuity and process ownership under real output scheduling. */ +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import { BrowserTerminal } from '../src/terminal.ts' +import { TerminalFollower } from '../src/stream.ts' +import type { TerminalAttachmentId, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts' + +const info: WebTerminalInfo = { id: 'terminal-test' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null } +const attachment = (id: string): TerminalAttachmentId => id as TerminalAttachmentId +const cleanups: (() => Promise)[] = [] +afterEach(async () => { await Promise.all(cleanups.splice(0).map(close => close())) }) + +function fixture() { + const output = new PassThrough() + const outcome = Promise.withResolvers() + const handle = { + pid: 123, output, done: outcome.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}), + inspectForeground: async () => undefined, signalForeground: async () => 123, + terminate: vi.fn(async () => { output.end(); outcome.resolve({ exitCode: 0, signal: null }) }), + } + const checked: SubprocessTerminalHandle = handle + const terminal = new BrowserTerminal(checked, info, 100, 100_000) + cleanups.push(() => terminal.close()) + return { terminal, output, outcome, handle } +} + +async function attach(terminal: BrowserTerminal, id = 'first') { + const controller = new AbortController() + const iterator = terminal.follow(attachment(id), controller.signal)[Symbol.asyncIterator]() + const baseline = await readFrame(iterator) + cleanups.push(async () => { controller.abort(); await iterator.return?.() }) + return { controller, iterator, baseline } +} + +describe('BrowserTerminal', () => { + it('restores the screen after detach without re-executing the shell or replaying duplicate output', async () => { + const { terminal, output, handle } = fixture() + const first = await attach(terminal) + expect(first.baseline).toMatchObject({ type: 'snapshot', sequence: 0 }) + output.write(Buffer.from('hello\r\n')) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', sequence: 1, data: 'hello\r\n' }) + first.controller.abort() + await first.iterator.return?.() + expect(handle.terminate).not.toHaveBeenCalled() + output.write(Buffer.from('world')) + await expect.poll(() => terminal.info.state).toBe('running') + const second = await attach(terminal, 'second') + // The baseline and its output queue share the same ordered screen write queue. + const frames = [second.baseline] + if (second.baseline.type === 'snapshot' && !second.baseline.screen.includes('world')) frames.push(await readFrame(second.iterator)) + expect(JSON.stringify(frames)).toContain('world') + expect(JSON.stringify(frames)).toContain('hello') + expect(handle.terminate).not.toHaveBeenCalled() + }) + + it('preserves split UTF-8, gives the newest attachment input, and resizes both PTY and recovery screen', async () => { + const { terminal, output, handle } = fixture() + const first = await attach(terminal) + const encoded = Buffer.from('终端') + output.write(encoded.subarray(0, 2)) + output.write(encoded.subarray(2)) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' }) + const second = await attach(terminal, 'second') + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('another attachment') + await terminal.write(attachment('second'), '\t') + expect(handle.write).toHaveBeenCalledWith('\t') + await terminal.resize(attachment('second'), 100, 30) + expect(handle.resize).toHaveBeenCalledWith(100, 30) + expect(terminal.info).toMatchObject({ cols: 100, rows: 30 }) + second.controller.abort() + await second.iterator.return?.() + expect(terminal.info.controllerId).toBeUndefined() + }) + + it('keeps exit facts and screen until explicit cleanup and never starts a replacement process', async () => { + const { terminal, output, outcome, handle } = fixture() + const first = await attach(terminal) + output.end('done') + outcome.resolve({ exitCode: 7, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'done' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited', exitCode: 7 } }) + expect(handle.terminate).not.toHaveBeenCalled() + const second = await attach(terminal, 'second') + expect(second.baseline).toMatchObject({ type: 'snapshot', info: { state: 'exited', exitCode: 7 } }) + await terminal.close() + expect(handle.terminate).toHaveBeenCalledOnce() + }) + + it('drains final output and exit state before closing followers', async () => { + const { terminal, handle, output, outcome } = fixture() + const first = await attach(terminal) + vi.mocked(handle.terminate).mockImplementationOnce(async () => { + output.end('FINAL OUTPUT\r\n') + outcome.resolve({ exitCode: 0, signal: null }) + }) + await terminal.close() + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'FINAL OUTPUT\r\n' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) + expect((await first.iterator.next()).done).toBe(true) + }) + + it('rejects input before attachment, during close and after process exit', async () => { + const { terminal, handle, output, outcome } = fixture() + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('another attachment') + const first = await attach(terminal) + output.end() + outcome.resolve({ exitCode: 0, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('not running') + await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('not running') + const closing = terminal.close() + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('not running') + await closing + expect(handle.write).not.toHaveBeenCalled() + expect(handle.resize).not.toHaveBeenCalled() + }) + + it('preserves the input controller when an older follower detaches', async () => { + const { terminal, handle } = fixture() + const first = await attach(terminal) + const second = await attach(terminal, 'second') + first.controller.abort() + await first.iterator.return?.() + expect(terminal.info.controllerId).toBe(attachment('second')) + terminal.rename('build output') + expect(await readFrame(second.iterator)).toMatchObject({ type: 'state', info: { title: 'build output', controllerId: 'second' } }) + await terminal.write(attachment('second'), 'pwd\r') + expect(handle.write).toHaveBeenCalledWith('pwd\r') + }) + + it('does not grant input to attachments cancelled before their snapshot is ready', async () => { + const { terminal, handle } = fixture() + const aborted = new AbortController() + aborted.abort(new Error('already detached')) + await expect(terminal.follow(attachment('cancelled'), aborted.signal)[Symbol.asyncIterator]().next()).rejects.toThrow('already detached') + await attach(terminal) + const writing = Promise.withResolvers() + const written = Promise.withResolvers() + handle.write.mockImplementationOnce(async () => { writing.resolve(undefined); await written.promise }) + const pendingWrite = terminal.write(attachment('first'), 'pwd\r') + try { + await writing.promise + const abort = new AbortController() + const pendingAttachment = terminal.follow(attachment('late'), abort.signal)[Symbol.asyncIterator]().next() + const rejected = expect(pendingAttachment).rejects.toThrow('detached while waiting') + abort.abort(new Error('detached while waiting')) + written.resolve(undefined) + await pendingWrite + await rejected + expect(terminal.info.controllerId).toBe(attachment('first')) + } finally { written.resolve(undefined) } + }) + + it('continues accepting operations after a provider write or resize fails', async () => { + const { terminal, handle } = fixture() + await attach(terminal) + handle.write.mockRejectedValueOnce(new Error('input transport failed')) + handle.resize.mockRejectedValueOnce(new Error('resize transport failed')) + await expect(terminal.write(attachment('first'), 'failed')).rejects.toThrow('input transport failed') + await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('resize transport failed') + expect(terminal.info).toMatchObject({ cols: 80, rows: 24 }) + await terminal.write(attachment('first'), 'accepted') + await terminal.resize(attachment('first'), 90, 25) + expect(handle.write).toHaveBeenLastCalledWith('accepted') + expect(terminal.info).toMatchObject({ cols: 90, rows: 25 }) + }) + + it.each([new Error('process wait failed'), 'remote process wait failed'])('publishes a failed process outcome and retains its recovery screen: %s', async (failure) => { + const { terminal, output, outcome } = fixture() + const first = await attach(terminal) + output.end('last output') + outcome.reject(failure) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'last output' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: failure instanceof Error ? failure.message : failure } }) + const second = await attach(terminal, 'second') + expect(second.baseline).toMatchObject({ type: 'snapshot', screen: 'last output', info: { state: 'failed' } }) + }) + + it('publishes output-stream failure even when the process wait succeeds', async () => { + const { terminal, output, outcome } = fixture() + const first = await attach(terminal) + output.destroy(new Error('output transport failed')) + outcome.resolve({ exitCode: 0, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: 'output transport failed' } }) + }) + + it('flushes incomplete UTF-8 at EOF before publishing the process exit', async () => { + const { terminal, output, outcome } = fixture() + const first = await attach(terminal) + output.end(Buffer.from([0xe7, 0xbb])) + outcome.resolve({ exitCode: 0, signal: null }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '�' }) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) + }) + + it('shares concurrent close attempts and permits retry after termination fails', async () => { + const { terminal, handle } = fixture() + await attach(terminal) + handle.terminate.mockRejectedValueOnce(new Error('process range remains alive')) + const first = terminal.close() + expect(terminal.close()).toBe(first) + await expect(first).rejects.toThrow('remains alive') + await terminal.write(attachment('first'), 'retry cleanup next') + await terminal.close() + expect(handle.terminate).toHaveBeenCalledTimes(2) + }) + + it('reports overflow rather than silently discarding output', async () => { + const follower = new TerminalFollower(64) + follower.push({ type: 'output', sequence: 1, data: 'x'.repeat(128) }) + await expect(follower.read(new AbortController().signal)[Symbol.asyncIterator]().next()).rejects.toThrow('buffer') + }) +}) + +async function readFrame(iterator: AsyncIterator): Promise { + const result = await iterator.next() + if (result.done === true) throw new Error('Terminal stream ended before its expected frame') + return result.value +} diff --git a/packages/api/terminal-controller/tsconfig.client.json b/packages/api/terminal-controller/tsconfig.client.json new file mode 100644 index 0000000000..4b78263b57 --- /dev/null +++ b/packages/api/terminal-controller/tsconfig.client.json @@ -0,0 +1,37 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/types.ts", + "src/client/close-requests.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../gateway/tsconfig.client.json" + }, + { + "path": "../../client/store" + }, + { + "path": "../../core/session" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/crypto" + } + ] +} diff --git a/packages/api/terminal-controller/tsconfig.host.json b/packages/api/terminal-controller/tsconfig.host.json new file mode 100644 index 0000000000..132c120afa --- /dev/null +++ b/packages/api/terminal-controller/tsconfig.host.json @@ -0,0 +1,53 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/types.ts", + "src/terminal.ts", + "src/stream.ts", + "src/shells.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../subprocess/subprocess" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../util/deque" + } + ] +} diff --git a/packages/api/terminal-controller/tsconfig.json b/packages/api/terminal-controller/tsconfig.json new file mode 100644 index 0000000000..2eca820546 --- /dev/null +++ b/packages/api/terminal-controller/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.host.json" + }, + { + "path": "./tsconfig.client.json" + } + ] +} diff --git a/packages/api/terminal-controller/tsdown.config.ts b/packages/api/terminal-controller/tsdown.config.ts new file mode 100644 index 0000000000..cb9d252eb2 --- /dev/null +++ b/packages/api/terminal-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-terminal-controller', + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index cf4603031f..77664dd497 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -107,6 +107,8 @@ # Workspace file service: bounded read, directory listing, and the # agent-write change feed inside the session workspace root. + - id: terminal-controller + name: '@deepseek-ai/dsh-api-terminal-controller' - id: workspace-files name: '@deepseek-ai/dsh-api-workspace-files' @@ -231,6 +233,8 @@ name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' # The right Sidebar's workspace file tree tab type. + - id: ui-sidebar-terminal + name: '@deepseek-ai/dsh-client-ui-sidebar-terminal' - id: ui-sidebar-files name: '@deepseek-ai/dsh-client-ui-sidebar-files' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 692a6be952..abf467259f 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -118,7 +118,9 @@ "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", - "open": "^11.0.0" + "open": "^11.0.0", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 7104855a97..996ef921db 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -10,7 +10,7 @@ */ import { readFile } from 'node:fs/promises' import { existsSync, globSync, readFileSync } from 'node:fs' -import { isBuiltin } from 'node:module' +import { createRequire, isBuiltin } from 'node:module' import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path' import { fileURLToPath } from 'node:url' import type { UserConfig } from 'tsdown' @@ -616,6 +616,7 @@ const SOURCEMAP_COMMENT = /\n\/\/# sourceMappingURL=.*\s*$/ /** Resolve an emitted JS asset import against its source-tree counterpart. */ function sourceAssetPath(source: string, importer: string): string { + if (!source.startsWith('.') && !isAbsolute(source)) return createRequire(importer).resolve(source) const emitted = resolvePath(dirname(importer), source) if (existsSync(emitted)) return emitted const boundary = emitted.indexOf(TYPES_MARKER) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index b8a821215f..0b4a9bd86c 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md -README.md: 128f0e41a1b1a90f39f4db36f1a8ca8dfa3089f7 -README.zh.md: 66f2e8d6a07635d7e94ee50395c274951911e517 +README.md: 1a0a34119cc91f2e7c829e8b0c1dc56ad74d2631 +README.zh.md: 7d611eb4a4d4daa0c00baa8658b8b3e84288471e diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 128f0e41a1..1a0a34119c 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -67,7 +67,7 @@ After every action the kit's settle planner keeps the surface populated: a docke The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the settle planner reseeds the current default page for the next expansion. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely. -State is memory-only. A reload returns every session to the collapsed default; switching sessions keeps each surface where it was. +Layout, open tabs, navigation parameters and selection are memory-only. A reload constructs the collapsed default; content plugins can then reopen their own live content, as [terminal recovery](../ui-sidebar-terminal/README.md#use-this-package) does. Switching sessions keeps each surface where it was. ## Extension seats @@ -93,12 +93,14 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store and reconciles records on its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller. +Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler can save background cleanup and return synchronously, allowing immediate removal, or return a Promise that the sidebar awaits before explicit close or replacement. A thrown or rejected handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close. + ## The guide Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule. -The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. +The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule replaces the guide with its page. An entry may set `revealIfOpened: false` to create another tab; omission keeps the normal reveal of an existing page. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. ## Copy @@ -118,7 +120,7 @@ None; this package neither assembles nor sends a provider request. -- **Memory-only.** Nothing is persisted; a reload starts every session collapsed. +- **Memory-only layout.** Sidebar state is not persisted; content-specific recovery cannot reproduce the previous layout or selection. - **No surface without a session.** State is keyed by session id, so the hero screen shows nothing on the right. - **Hard-coded stacking.** The panel and the float host use fixed z-index values because the client has no z-index token layer yet. - **Undo is not exposed.** The recorded sequence is stepped only through the `@internal` service methods; product controls are deliberately absent. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 66f2e8d6a0..7d611eb4a4 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" 停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史——settle planner 为下次展开重新播种当前默认页。浮动面板不参与这条规则:它们无论列是否展开都会渲染,其 tab 照常关闭。 -状态只在内存中。刷新会让每个会话回到折叠的默认态;切换会话则让每个停靠面留在原处。 +布局、打开的标签页、导航参数和选中项只在内存中。刷新会构造折叠的默认态;内容插件随后可重新打开自己仍存活的内容,例如[终端恢复](../ui-sidebar-terminal/README.zh.md#use-this-package)。切换会话则让每个停靠面留在原处。 ## 扩展席位 @@ -93,12 +93,14 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私有装配回调收养各会话的store,并在每次提交时对齐记录。记录消失或插件卸载才中止signal,收起和切会话不销毁记录;undo恢复的是新occurrence。`useTabInfo()` 组合框架绑定的store与导航hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。 +标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 可以保存后台清理任务并同步返回,让标签页立即移除;也可以返回 Promise,让侧栏在显式关闭或替换前等待。handler 抛错或拒绝时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。 + ## 引导页 默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。 -引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 +引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会用对应页面替换引导页。条目可设置 `revealIfOpened: false` 来新建另一个 tab;省略时保留聚焦已有页面的默认行为。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 ## 文案 @@ -118,7 +120,7 @@ None; this package neither assembles nor sends a provider request. -- **只在内存中。** 不持久化任何东西;刷新让每个会话从折叠态开始。 +- **布局只在内存中。** 侧栏状态不持久化;内容自身的恢复无法还原此前的布局或选中项。 - **没有会话就没有停靠面。** 状态按会话 id 键控,因此 hero 画面右侧什么都不显示。 - **硬编码的层叠。** 面板与浮窗宿主使用固定的 z-index 值,因为客户端还没有 z-index token 层。 - **未暴露撤销。** 记录的序列只能通过 `@internal` 服务方法步进;产品控件是有意缺席的。 diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index f964ee97c6..87fe12883f 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -126,7 +126,7 @@ export interface SidebarRightTabActions { */ openTab(kind: K, options?: SidebarRightTabPlacement & { readonly params?: SidebarRightTabParamsFor }): void /** Close this tab. */ - close(): void + close(): void | Promise } /** Live information shared by a tab's body, title, and guide replacement. */ diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 6e2df4fe40..556296f186 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -49,7 +49,7 @@ export type { ExpandButtonProps } from './shell/ExpandButton.tsx' export type { SidebarRightState, SurfaceState } from './stores.ts' export type { ISidebarRight, SidebarRightBinding, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, - SidebarRightPlacement, SurfaceActions, + SidebarRightPlacement, SidebarRightCloseHandler, SurfaceActions, } from './service.ts' export type { SidebarRightGuideBox, SidebarRightGuideEntry, SidebarRightTabClaim, SidebarRightTabDefinition, @@ -135,7 +135,7 @@ export function apply(ctx: ClientContext): void { }, } const layout: ILayout = ctx.layout - const injected: Omit = { + const injected: Omit = { syncPresentation({ shown, track, fullscreen }) { if (shown) layout.openRightbar(track, fullscreen) else layout.closeRightbar() @@ -162,6 +162,10 @@ export function apply(ctx: ClientContext): void { store, inject: (sessionId): SidebarRightInjected => ({ ...injected, + closeTab: (tabId) => { + const pending = controller.closeIn(sessionId, tabId) + if (pending !== undefined) void pending.catch((error: unknown) => { console.error('Sidebar tab close failed:', error) }) + }, keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation }, occurrence: tab => controller.tabDomain.occurrence(sessionId, tab), }), diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 81e003b06a..f68b2fe1f9 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -28,13 +28,13 @@ * callers use the service's navigation methods. */ import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' -import { activeDockPaneId, canSplit, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' +import { activeDockPaneId, canSplit, findContentTab, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightTabParamsFor } from './contract/params.ts' import { pageAddress } from './contract/seed.ts' import type { SidebarRightTabClaim, SidebarRightTabRegistry } from './tab-registry.ts' -import type { SidebarRightState, SurfaceState } from './stores.ts' +import { canCloseTab, type SidebarRightState, type SurfaceState } from './stores.ts' import type { createSidebarRightStore } from './stores.ts' import { TabDomain, type PinResource } from './tab-domain.ts' @@ -134,6 +134,9 @@ export interface SidebarRightOpenTabOptions extends S /** The scheme every resource address carries; anything else is not a resource this face opens. */ const RESOURCE_SCHEME = 'dsh-resource://' +/** Explicit close/replacement hook. Return void for immediate removal, or a promise to defer removal until cleanup succeeds. */ +export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => void | Promise + /** The outward right-Sidebar face (`ctx.sidebarRight`). */ export interface ISidebarRight { /** @@ -160,7 +163,7 @@ export interface ISidebarRight { * Close one tab of the mounted session; the sole docked guide remains open. * @param tabId - the tab to close. */ - close(tabId: TabId): void + close(tabId: TabId): void | Promise /** * The active tab of the active pane. * @returns the record, or `undefined` when no seat is mounted. @@ -203,6 +206,20 @@ export interface ISidebarRight { /** Cross-plugin right-Sidebar face (ctx.sidebarRight). */ export class SidebarRightController implements ISidebarRight { private binding: SidebarRightBinding | undefined + private readonly closeHandlers = new Map() + private readonly closing = new Map>() + + /** + * Register resource cleanup before explicit removal. Failure preserves the tab. + * @param kind - tab kind owned by the registering plugin. + * @param handler - saves background cleanup synchronously, or returns cleanup to await before removal. + * @returns an effect-scoped unregister callback. + */ + registerCloseHandler(kind: string, handler: SidebarRightCloseHandler): () => void { + if (this.closeHandlers.has(kind)) throw new Error(`sidebarRight: close handler already registered for ${kind}`) + this.closeHandlers.set(kind, handler) + return () => { if (this.closeHandlers.get(kind) === handler) this.closeHandlers.delete(kind) } + } /** * The Tab domain this controller navigates into; synced from each adopted @@ -291,10 +308,33 @@ export class SidebarRightController implements ISidebarRight { * Not part of `ISidebarRight`: the Tab domain's path. * @param sessionId - the session the tab is in. * @param tabId - the tab to close. + * @returns resource cleanup completion when the tab type owns an asynchronous close handler. */ - closeIn(sessionId: SessionId, tabId: TabId): void { + closeIn(sessionId: SessionId, tabId: TabId): void | Promise { const actions = this.actionsFor(sessionId) - if (actions !== undefined) actions.closeTab(sessionId, tabId) + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] + if (actions === undefined || surface === undefined) return + const tab = surface.layout.tabs[tabId] + if (tab === undefined || !canCloseTab(surface, tabId)) return + return this.removeAfterCleanup(sessionId, tab, () => { actions.closeTab(sessionId, tabId) }) + } + + private removeAfterCleanup(sessionId: SessionId, tab: TabRecord, commit: () => void): void | Promise { + const handler = this.closeHandlers.get(tab.kind) + if (handler === undefined) { commit(); return } + const key = `${sessionId}:${tab.id}` + const existing = this.closing.get(key) + if (existing !== undefined) return existing + const adoption = this.adopted.get(sessionId) + const occurrence = this.tabDomain.occurrence(sessionId, tab) + const completion = handler(sessionId, tab) + if (completion === undefined) { commit(); return } + const pending = completion.then(() => { + if (this.adopted.get(sessionId) !== adoption || occurrence.signal.aborted) return + commit() + }).finally(() => { this.closing.delete(key) }) + this.closing.set(key, pending) + return pending } /** Claim a resource and place it in one session; an address outside the scheme or one no type claims throws. */ @@ -332,22 +372,31 @@ export class SidebarRightController implements ISidebarRight { placement: SidebarRightPlacement, params: SidebarRightNavigationParams, ): void { - actions.openContent(sessionId, { + const commit = (): void => { actions.openContent(sessionId, { kind: claim.kind, contentId: claim.contentId, title: claim.title, ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, ...placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab }, ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, - }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) + }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) } + const layout = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout + const replaced = placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab] + const revealed = layout === undefined || placement.revealIfOpened === false + ? undefined : findContentTab(layout, claim.contentId, claim.kind) + if (replaced === undefined || replaced.id === revealed) { commit(); return } + const pending = this.removeAfterCleanup(sessionId, replaced, commit) + if (pending !== undefined) void pending.catch((error: unknown) => { console.error('Sidebar tab replacement failed:', error) }) } /** * Close one tab of the mounted session; the sole docked guide remains open. * @param tabId - the tab to close. + * @returns resource cleanup completion when the tab type owns an asynchronous close handler. */ - close(tabId: TabId): void { + close(tabId: TabId): void | Promise { const { sessionId, actions } = this.require() + if (this.adopted.has(sessionId)) return this.closeIn(sessionId, tabId) actions.closeTab(sessionId, tabId) } diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 355eedc190..476134c47d 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -97,6 +97,8 @@ export interface SidebarRightInjected { * the guide opened by kind, through the same path as every other open. */ readonly openTab: (kind: string, options?: SidebarRightOpenTabOptions) => void + /** Close through the resource owner's cleanup handler. */ + readonly closeTab: (tabId: TabId) => void readonly hooks: { readonly tabTypes: HostObservable } @@ -123,6 +125,7 @@ interface PanelProps { readonly t: RightbarSeatProps['t'] readonly renderSlot: Children['renderSlot'] readonly openTab: SidebarRightInjected['openTab'] + readonly closeTab: SidebarRightInjected['closeTab'] readonly useTabTypes: RightbarSeatProps['useTabTypes'] readonly useTabNavigation: RightbarSeatProps['useTabNavigation'] readonly useStore: Store['useStore'] @@ -145,7 +148,7 @@ function guideIn(layout: LayoutState, paneId: PaneId): TabId | undefined { * @param openTab - the navigation face's `openTab`, which the strip's add control asks for a guide through. * @returns the intents the kit reports gestures to. */ -export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab']): DockIntents { +export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab'], closeTab?: PanelProps['closeTab']): DockIntents { return { focusTab: (tabId) => { actions.focusTab(sessionId, tabId) }, focusPane: (paneId) => { actions.focusPane(sessionId, paneId) }, @@ -155,7 +158,7 @@ export function intentsFor(sessionId: SessionId, actions: Store['actions'], open // guides in other panes; the store settles the open on a guide the pane // already holds, so the ask is idempotent all the same. addTab: (paneId) => { openTab(GUIDE_KIND, { paneId, revealIfOpened: false }) }, - closeTab: (tabId) => { actions.closeTab(sessionId, tabId) }, + closeTab: closeTab ?? ((tabId) => { actions.closeTab(sessionId, tabId) }), duplicateTab: (tabId) => { actions.duplicateTab(sessionId, tabId) }, floatTab: (tabId, rect?: FloatRect) => { actions.floatTab(sessionId, tabId, rect) }, unfloatPane: (paneId) => { actions.unfloatPane(sessionId, paneId) }, @@ -311,7 +314,7 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject guideIn(surface.layout, paneId) === undefined} canCloseTab={tabId => canCloseTab(surface, tabId)} - intents={intentsFor(sessionId, actions, openTab)} + intents={intentsFor(sessionId, actions, openTab, panel.closeTab)} labels={dockLabels(t)} renderTab={bodiesFor(panel)} renderTabTitle={titlesFor(panel)} @@ -334,7 +337,7 @@ function Floats(panel: PanelProps): ReactNode { canCloseTab(surface, tabId)} - intents={intentsFor(sessionId, actions, openTab)} + intents={intentsFor(sessionId, actions, openTab, panel.closeTab)} labels={dockLabels(t)} renderTab={bodiesFor(panel)} renderTabTitle={titlesFor(panel)} @@ -351,7 +354,7 @@ function Floats(panel: PanelProps): ReactNode { * which session it is acting on, because this is the seat that knows both. */ export function RightbarSeat({ - sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab, + sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab, closeTab, useTabTypes, useTabNavigation, occurrence, }: RightbarSeatProps): ReactNode { // One store instance per session, so this map holds this session's surface. @@ -416,7 +419,7 @@ export function RightbarSeat({ if (surface === undefined) return null const panel: PanelProps = { - sessionId, actions, t, renderSlot, surface, openTab, useTabTypes, useTabNavigation, useStore, occurrence, + sessionId, actions, t, renderSlot, surface, openTab, closeTab, useTabTypes, useTabNavigation, useStore, occurrence, fullscreen, autoFullscreen, reportRoom, } return ( diff --git a/packages/client/ui-sidebar-right/src/client/tab-domain.ts b/packages/client/ui-sidebar-right/src/client/tab-domain.ts index 53b259584e..533cf866c4 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-domain.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-domain.ts @@ -34,7 +34,7 @@ export interface SidebarRightNavigator { /** Open a page type in one session; see `ISidebarRight.openTab`. */ openTabIn(sessionId: SessionId, kind: string, options?: SidebarRightOpenTabOptions): void /** Close a tab of one session. */ - closeIn(sessionId: SessionId, tabId: TabId): void + closeIn(sessionId: SessionId, tabId: TabId): void | Promise } /** `ctx.resources.pin`: hold an address's content open for as long as `signal` lives. */ @@ -175,7 +175,7 @@ export class TabDomain { openTab: (kind, options = {}) => { navigator.openTabIn(sessionId, kind, { ...place(options), params: options.params }) }, - close: () => { navigator.closeIn(sessionId, tabId) }, + close: () => navigator.closeIn(sessionId, tabId), }, } this.session(sessionId).set(tabId, held) diff --git a/packages/client/ui-sidebar-right/src/client/tab-registry.ts b/packages/client/ui-sidebar-right/src/client/tab-registry.ts index 47c737b98f..5284716d5f 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-registry.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-registry.ts @@ -68,6 +68,8 @@ export interface SidebarRightGuideEntry { readonly title: () => string /** Optional glyph, drawn before the title. */ readonly icon?: ComponentType + /** Defaults to true; false makes this entry create another tab instead of focusing an existing one. */ + readonly revealIfOpened?: boolean } /** A guide entry as the registry lists it: with the kind of the type that contributed it, which is what picking it opens. */ diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx index 5a9851739a..b3d07685dd 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx @@ -67,7 +67,10 @@ export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain }: Guid const options = { hookContext: useTabInfo, fallback: ( - { tab.actions.openTab(entry.kind, { replaceTab: true }) }} /> + { tab.actions.openTab(entry.kind, { + replaceTab: true, + ...entry.revealIfOpened === undefined ? {} : { revealIfOpened: entry.revealIfOpened }, + }) }} /> ), } satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> } return renderSlotChain('sidebar.right.tab.guide', {}, options) diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index 543a18f666..6dbce4eea3 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -163,7 +163,7 @@ describe('ui-sidebar-right apply', () => { // Held and pinned from the store's own commit: no seat synced anything. const occurrence = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide) expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', occurrence.signal) - occurrence.tabActions.close() + void occurrence.tabActions.close() expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined() expect(occurrence.signal.aborted).toBe(true) }) @@ -199,7 +199,7 @@ describe('ui-sidebar-right apply', () => { await fiber.dispose() expect(signal.aborted).toBe(true) // The adoption went with the plugin: a late action from the dead occurrence changes nothing. - tabActions.close() + void tabActions.close() expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeDefined() expect(ctx.get('sidebarRight')).toBeUndefined() expect(ctx.get('sidebarRightTabs')).toBeUndefined() diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index 09955a10b5..3f88ad5e6a 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -73,7 +73,7 @@ describe('GuideBody', () => { cleanup() }) - it('picking a box opens that type in the guide\'s own place', () => { + it('picking a box without a reveal preference preserves the default opening behavior', () => { const { view, openTab } = mountGuide([box('files', 10)]) const entry = view.container.querySelector('[data-sidebar-right-guide-entry="files"]') if (entry === null) throw new Error('expected the files box') @@ -82,6 +82,12 @@ describe('GuideBody', () => { cleanup() }) + it.each([false, true])('passes an entry\'s explicit revealIfOpened=%s preference when replacing the guide', (revealIfOpened) => { + const { view, openTab } = mountGuide([{ ...box('terminal', 20), revealIfOpened }]) + fireEvent.click(view.getByRole('button', { name: 'terminal title' })) + expect(openTab).toHaveBeenCalledExactlyOnceWith('terminal', { replaceTab: true, revealIfOpened }) + }) + it('draws an empty guide while no type contributed an entry, and follows the registry when one does', () => { const { view, guideEntries, boxes } = mountGuide([]) expect(view.container.querySelector('[data-sidebar-right-guide]')).not.toBeNull() diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 22c8527b8b..2ad4b7adbe 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -138,7 +138,7 @@ describe('RightbarSeat presentation', () => { if (entryCount !== 1) { expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) const before = h.layout() - act(() => { h.controller.close(initial.id) }) + act(() => { void h.controller.close(initial.id) }) expect(h.layout()).toBe(before) fireEvent.contextMenu(element(h.view.container, '[data-dockkit-tab]')) expect(document.querySelector('[data-dockkit-tab-menu] [role^="menuitem"]')).toBeNull() @@ -146,7 +146,7 @@ describe('RightbarSeat presentation', () => { return } expect(h.view.container.querySelector(`[data-dockkit-tab-close="${initial.id}"]`)).not.toBeNull() - act(() => { h.controller.close(initial.id) }) + act(() => { void h.controller.close(initial.id) }) expect(h.layout().expanded).toBe(false) const reseeded = Object.values(h.layout().tabs)[0]! expect(reseeded.kind).toBe('text') @@ -157,12 +157,12 @@ describe('RightbarSeat presentation', () => { expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${reseeded.id}"]`)).not.toBeNull() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).not.toBeNull() - act(() => { h.controller.close(reseeded.id) }) + act(() => { void h.controller.close(reseeded.id) }) expect(h.layout().tabs[reseeded.id]).toBeUndefined() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).toBeNull() const preview = h.open('ordinary.txt') expect(h.view.container.querySelector(`[data-dockkit-tab-close="${preview.id}"]`)).not.toBeNull() - act(() => { h.controller.close(preview.id) }) + act(() => { void h.controller.close(preview.id) }) expect(h.layout().tabs[preview.id]).toBeUndefined() expect(Object.keys(h.layout().tabs)).toEqual([guide.id]) expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) @@ -437,7 +437,7 @@ describe('slot-owned useTabInfo', () => { expect(Object.values(h.layout().tabs).map(tab => tab.title)).toContain('b.txt') expect(h.controller.active()?.contentId).toBe(otherTab.contentId) expect(h.bodies.get(otherTab.id)?.tab.navigation.params).toEqual({ line: 9 }) - act(() => { info.tab.actions.close() }) + act(() => { void info.tab.actions.close() }) expect(info.tab.signal.aborted).toBe(true) expect(otherInfo.tab.signal.aborted).toBe(false) await h.runtime.sessions.setCurrent(SESSION) @@ -579,3 +579,22 @@ describe('intentsFor — the kit\'s gestures as one session\'s store actions', ( expect(openTab).toHaveBeenCalledWith('guide', { paneId: PANE_1, revealIfOpened: false }) }) }) + +it('keeps a resource tab and reports cleanup rejection from its close button', async () => { + const h = await mountSeat() + const tab = h.open('terminal') + const failure = new Error('process still running') + const release = h.controller.registerCloseHandler('text', async () => { throw failure }) + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${tab.id}"]`)) + await expect.poll(() => logged.mock.calls).toEqual([['Sidebar tab close failed:', failure]]) + expect(h.layout().tabs[tab.id]).toBeDefined() + release() + fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${tab.id}"]`)) + expect(h.layout().tabs[tab.id]).toBeUndefined() + } finally { + logged.mockRestore() + release() + } +}) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index d5f1ec099d..6827aa5824 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -82,7 +82,7 @@ describe('SidebarRightController — opening', () => { const { controller } = harness() expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') expect(() => { controller.openTab('guide') }).toThrow('no session surface is mounted') - expect(() => { controller.close('tab1' as TabId) }).toThrow('no session surface is mounted') + expect(() => { void controller.close('tab1' as TabId) }).toThrow('no session surface is mounted') expect(() => { controller.toggleExpanded() }).toThrow('no session surface is mounted') expect(() => { controller.focus('tab1' as TabId) }).toThrow('no session surface is mounted') expect(() => { controller.split() }).toThrow('no session surface is mounted') @@ -283,7 +283,7 @@ describe('SidebarRightController — opening', () => { publish() controller.openResource('dsh-resource://file/session/s-test/a.txt') publish() - controller.close(tabOf('a.txt')) + void controller.close(tabOf('a.txt')) expect(titles()).not.toContain('a.txt') }) @@ -459,7 +459,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { // session's closes this session's, aborts its occurrence at once although // no seat draws the session, and the other keeps its own. expect(otherSurface.layout.tabs[guide.id]).toBeDefined() - guideOccurrence.tabActions.close() + void guideOccurrence.tabActions.close() expect(layout().tabs[guide.id]).toBeUndefined() expect(guideOccurrence.signal.aborted).toBe(true) expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface) @@ -479,14 +479,14 @@ describe('SidebarRightController — a tab\'s own actions', () => { const before = instance.getSnapshot().bySession tabActions.openResource(B_TXT) tabActions.openTab('guide') - tabActions.close() + void tabActions.close() expect(instance.getSnapshot().bySession).toBe(before) // Adopted, they land; released, they stop again. const release = adopt(SESSION, instance) tabActions.openResource(B_TXT) expect(titles()).toContain('b.txt') release() - tabActions.close() + void tabActions.close() expect(titles()).toContain('a.txt') }) @@ -578,3 +578,152 @@ describe('SidebarRightController — binding lifetime', () => { expect(() => { controller.toggleExpanded() }).not.toThrow() }) }) + +describe('explicit tab resource cleanup', () => { + it.each(['close', 'replace'] as const)('immediately removes the tab after a synchronous cleanup handler during %s', (operation) => { + const h = harness() + const release = h.adopt(SESSION, h.instance) + const unbind = h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + const tabId = h.tabOf('terminal') + const original = h.layout().tabs[tabId] + const handler = vi.fn(() => { expect(h.layout().tabs[tabId]).toBe(original) }) + const unregister = h.controller.registerCloseHandler('text', handler) + try { + const before = h.entries() + if (operation === 'close') expect(h.controller.close(tabId)).toBeUndefined() + else h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) + expect(handler).toHaveBeenCalledExactlyOnceWith(SESSION, original) + expect(h.layout().tabs[tabId]).toBeUndefined() + expect(h.entries()).toBe(before + 1) + expect(h.controller.closeIn(SESSION, tabId)).toBeUndefined() + expect(handler).toHaveBeenCalledOnce() + } finally { + unregister() + unbind() + release() + } + }) + + it('awaits cleanup once before closing and never closes on collapse', async () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + h.publish() + const tabId = h.tabOf('terminal') + const cleanup = Promise.withResolvers() + const handler = vi.fn(() => cleanup.promise) + h.controller.registerCloseHandler('text', handler) + h.controller.toggleExpanded() + expect(handler).not.toHaveBeenCalled() + const first = h.controller.closeIn(SESSION, tabId) + const second = h.controller.closeIn(SESSION, tabId) + expect(first).toBe(second) + expect(handler).toHaveBeenCalledOnce() + expect(h.layout().tabs[tabId]).toBeDefined() + cleanup.resolve(undefined) + await first + expect(h.layout().tabs[tabId]).toBeUndefined() + }) + + it('keeps the tab after failed cleanup and permits retry', async () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + h.publish() + const tabId = h.tabOf('terminal') + const handler = vi.fn().mockRejectedValueOnce(new Error('still running')).mockResolvedValue(undefined) + h.controller.registerCloseHandler('text', handler) + await expect(h.controller.closeIn(SESSION, tabId)).rejects.toThrow('still running') + expect(h.layout().tabs[tabId]).toBeDefined() + await h.controller.closeIn(SESSION, tabId) + expect(h.layout().tabs[tabId]).toBeUndefined() + }) + + it('awaits cleanup before replacing in a single history entry, and skips cleanup when revealing the same tab', async () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + const address = 'dsh-resource://file/session/s-test/terminal' + h.controller.openResource(address) + h.publish() + const tabId = h.tabOf('terminal') + const cleanup = Promise.withResolvers() + const handler = vi.fn(() => cleanup.promise) + h.controller.registerCloseHandler('text', handler) + h.controller.openResource(address, { replaceTab: tabId }) + expect(handler).not.toHaveBeenCalled() + const before = h.entries() + h.controller.openTab('guide', { replaceTab: tabId }) + expect(h.entries()).toBe(before) + expect(h.layout().tabs[tabId]).toBeDefined() + cleanup.resolve(undefined) + await expect.poll(() => h.layout().tabs[tabId]).toBeUndefined() + expect(h.entries()).toBe(before + 1) + }) +}) + +it('releases close handlers without a stale disposer removing a replacement', async () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + const first = vi.fn(async () => {}) + const second = vi.fn(async () => {}) + const release = h.controller.registerCloseHandler('text', first) + expect(() => h.controller.registerCloseHandler('text', second)).toThrow('already registered') + release() + const releaseSecond = h.controller.registerCloseHandler('text', second) + release() + h.controller.openResource('dsh-resource://file/session/s-test/one') + h.publish() + await h.controller.closeIn(SESSION, h.tabOf('one')) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledOnce() + releaseSecond() + h.controller.openResource('dsh-resource://file/session/s-test/two') + h.publish() + const tabId = h.tabOf('two') + h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) + expect(h.layout().tabs[tabId]).toBeUndefined() + expect(second).toHaveBeenCalledOnce() +}) + +it.each(['adoption', 'occurrence'] as const)('does not commit a pending close after its %s ends', async (lifetime) => { + const h = harness() + const release = h.adopt(SESSION, h.instance) + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + h.publish() + const tabId = h.tabOf('terminal') + const cleanup = Promise.withResolvers() + h.controller.registerCloseHandler('text', () => cleanup.promise) + const pending = h.controller.closeIn(SESSION, tabId) + if (lifetime === 'adoption') release() + else h.instance.actions.closeTab(SESSION, tabId) + const entries = h.entries() + cleanup.resolve(undefined) + await pending + expect(h.entries()).toBe(entries) + if (lifetime === 'adoption') expect(h.layout().tabs[tabId]).toBeDefined() +}) + +it('reports failed replacement cleanup and retains the original resource', async () => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + h.publish() + const tabId = h.tabOf('terminal') + const error = new Error('process cleanup failed') + h.controller.registerCloseHandler('text', async () => { throw error }) + const reported = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) + await expect.poll(() => reported.mock.calls).toEqual([['Sidebar tab replacement failed:', error]]) + expect(h.layout().tabs[tabId]).toBeDefined() + } finally { + reported.mockRestore() + } +}) diff --git a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts index 793bda8083..c51e9ca64e 100644 --- a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts @@ -191,7 +191,7 @@ describe('TabDomain — a tab\'s own actions', () => { const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId)) tabActions.openTab('files', { replaceTab: true }) expect(navigator.openTabIn).toHaveBeenLastCalledWith(SESSION, 'files', { replaceTab: tabId }) - tabActions.close() + void tabActions.close() expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId) }) diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml new file mode 100644 index 0000000000..cae62f6540 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md +README.md: cf915dc73c9009b4b91d084d9b7aee466e4f2c75 +README.zh.md: a7aa321d106d49061ae7b092f6e116cab153627c diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md new file mode 100644 index 0000000000..cf915dc73c --- /dev/null +++ b/packages/client/ui-sidebar-terminal/README.md @@ -0,0 +1,80 @@ +--- +description: "Open, recover and control interactive shell tabs in the Web right sidebar." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-terminal + +English | [中文](README.zh.md) + +## Summary + +Open the execution environment's default shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. The default shell starts immediately. Use the sidebar's **New tab** control to open more terminals. + +Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. + +Closing or replacing a terminal tab removes it immediately and ends its process in the background. A cleanup failure shows a small notification with **Retry**; retrying does not reopen the tab. Collapsing, switching tabs or Sessions, floating and fullscreen presentation preserve the process. + +Displaying a Session after a page reload reopens its retained Host terminals as new tabs. A recovery failure offers **Retry terminal recovery**. A recovered process that disappears reports an error instead of starting another shell. The [sidebar layout remains memory-only](../../client/ui-sidebar-right/README.md#state). + + +## Understand the implementation + +
+Implementation internals — click to expand + +This plugin registers the `terminal` type and body/title seats with the right sidebar. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY. + +A Session header contribution queries Host terminals and opens recovery tabs. Their navigation parameters carry `terminalId` only within the current page; a recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work. + +
+ + +## Further Exploration + +- [Subprocess](../../subprocess/subprocess/README.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md) + + +## Model Experience + +None, as this package handles user terminal interaction without adding model input. + +#### KV Cache effect + +None; terminal output travels only between the browser and Host. + +## Known Limitations and Deferred Work + + + +- The default shell or native PTY startup can fail. The tab reports the failure without launching a different shell. +- Completion menus and inline suggestions depend on shell configuration. The Web UI adds no independent completion engine. +- Terminal history is bounded. The feature does not send terminal output to the Agent, provide split terminal panes inside a tab, or restore processes after Host restart. + + +### Dev Note + +
+Working context for maintainers — click to expand + +No runtime invariant companion is published. One owner orders terminal metadata and screen updates; the provider exposes no independently observed dimensions to compare. + +
diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md new file mode 100644 index 0000000000..a7aa321d10 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -0,0 +1,80 @@ +--- +description: "在 Web 右侧栏打开、恢复和控制交互式 shell 标签页。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-sidebar-terminal + +[English](README.md) | 中文 + +## 概述 + +在对话旁打开执行环境的默认 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 + +## 目录 + +- [使用此包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。默认 shell 会立即启动。通过侧栏的**新标签页**控件打开更多终端。 + +双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 + +关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败时显示带**重试**操作的轻量通知;重试不会重新打开标签页。折叠、切换标签页或 Session、浮动和全屏都保留进程。 + +刷新页面后显示某个 Session,会把 Host 保留的终端重新打开为新标签页。恢复失败时可点击**重试恢复终端**。恢复目标进程消失时显示错误,不启动另一个 shell。[侧栏布局仅保存在内存中](../../client/ui-sidebar-right/README.zh.md#state)。 + + +## 了解实现 + +
+实现细节 + +插件向右侧栏注册 `terminal` 类型及正文和标题 seat。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。 + +Session header contribution 查询 Host 终端并打开恢复标签页。导航参数中的 `terminalId` 只在当前页面内保留;恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。 + +
+ + +## 延伸阅读 + +- [Subprocess](../../subprocess/subprocess/README.zh.md) +- [Right Sidebar](../../client/ui-sidebar-right/README.zh.md) +- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md) + + +## 模型体验 + +无;此包只处理用户交互式终端,不向模型请求添加内容。 + +#### KV 缓存影响 + +无;终端输出只在浏览器与 Host 之间传输。 + +## 已知限制与延期工作 + + + +- 默认 shell 或原生 PTY 可能启动失败。标签页显示错误,不启动其他 shell。 +- 补全菜单和内联建议取决于 shell 配置,Web UI 不提供独立补全引擎。 +- 终端历史有上限。此功能不向 Agent 发送终端输出,不在单个标签页内拆分终端,也不在 Host 重启后恢复进程。 + + +### 开发备注 + +
+维护说明 + +不发布运行时 invariant companion。终端元数据与屏幕更新由同一对象按序写入,没有独立的进程尺寸观测可供比较。 + +
diff --git a/packages/client/ui-sidebar-terminal/package.json b/packages/client/ui-sidebar-terminal/package.json new file mode 100644 index 0000000000..19a008c6c8 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/package.json @@ -0,0 +1,76 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-sidebar-terminal", + "description": "Interactive shell tabs for the right Sidebar", + "version": "0.1.5-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-sidebar-terminal" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-terminal-controller", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", + "react": "^18.2.0", + "clsx": "^2.0.0", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", + "@xterm/xterm": "^6.0.0", + "@xterm/addon-fit": "^0.11.0", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css new file mode 100644 index 0000000000..811de9cacc --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css @@ -0,0 +1,9 @@ +.root { display: flex; flex: 1; flex-direction: column; box-sizing: border-box; height: 100%; min-height: 0; padding-top: 8px; color: var(--dsw-alias-label-primary); font-size: 13px; } +.title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 1px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; } +.screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; background: #17191d; } +.screen :global(.xterm) { height: 100%; } +.status { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); } +.root button { padding: 6px 10px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } +.root button:hover { background: var(--dsw-alias-bg-l2); } +.error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx new file mode 100644 index 0000000000..b9916e0935 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx @@ -0,0 +1,126 @@ +/** Sidebar terminal screen and connection recovery. */ +import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { TerminalInjected } from './face.ts' +import type {} from './locales.ts' +import '@xterm/xterm/css/xterm.css' +import css from './TerminalBody.module.css' + +/** Standard sidebar owner share plus terminal model and localized copy. */ +export type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'sidebarTerminal'> & InjectFace + +/** + * Mount an automatically started or restored terminal. + * @param props - sidebar occurrence, model lookup and translated copy. + * @returns the terminal screen and any pending or exceptional state. + */ +export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyProps): ReactNode { + const { tab } = useTabInfo() + const model = view(tab.id) + const state = useTerminal(tab.id) + useEffect(() => model.mount(), [model]) + if (state === undefined) return null + const error = state.error ?? state.info?.error + let status: string | undefined + if (state.phase === 'idle' || state.phase === 'loading') status = t('loading') + else if (state.phase === 'creating' || state.phase === 'connecting' || state.phase === 'disconnected') status = t(state.phase) + else if (state.info?.state === 'exited') status = t('exited', { code: String(state.info.exitCode ?? '—') }) + else if (state.info?.state === 'failed') status = t('unavailable') + else if (state.phase === 'closed') status = t('closed') + const retry = state.phase === 'failed' || state.phase === 'disconnected' + const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable + return ( +
+ {(status !== undefined || retry || readOnly) &&
+ {status} + {readOnly && <>{t('readonly')} } + {retry && (state.info === undefined + ? + : )} +
} + {state.info !== undefined && } + {error !== undefined &&

{t('failed', { message: error })}

} +
+ ) +} + +/* oxlint-disable typescript/no-non-null-assertion -- React sets the DOM ref, then these effects initialize and use the emulator. */ +function TerminalScreen({ state, model, visible, label }: { + state: TerminalViewState + model: TerminalView + visible: boolean + label: string +}): ReactNode { + const element = useRef(null) + const terminal = useRef() + const fit = useRef() + const lastRevision = useRef(0) + const current = useRef({ state, visible }) + current.current = { state, visible } + + useLayoutEffect(() => { + const node = element.current! + const xterm = new Terminal({ cursorBlink: true, fontSize: 13, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollback: current.current.state.environment?.scrollback ?? 0, theme: { background: '#17191d', foreground: '#e7e9ee' } }) + const addon = new FitAddon() + xterm.loadAddon(addon) + xterm.open(node) + xterm.textarea?.setAttribute('aria-label', label) + terminal.current = xterm + fit.current = addon + lastRevision.current = 0 + const input = xterm.onData((data) => { model.write(data) }) + const measure = (): void => { + if (!current.current.visible || !current.current.state.writable || node.clientWidth === 0 || node.clientHeight === 0) return + fitScreen(xterm, addon, current.current.state, model) + } + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => { + observer.disconnect() + input.dispose() + xterm.dispose() + terminal.current = undefined + fit.current = undefined + } + }, [model]) + + useLayoutEffect(() => { + const xterm = terminal.current! + const render = state.render + if (render === undefined || render.revision <= lastRevision.current) return + lastRevision.current = render.revision + if (render.frame.type === 'snapshot') { + xterm.reset() + xterm.resize(render.frame.info.cols, render.frame.info.rows) + } + xterm.write(render.frame.type === 'snapshot' ? render.frame.screen : render.frame.data, () => { model.acknowledge(render.revision) }) + }, [state.render, model]) + + useLayoutEffect(() => { + const xterm = terminal.current! + xterm.options.disableStdin = !state.writable + if (visible && state.writable && element.current?.clientWidth && element.current.clientHeight) { + fitScreen(xterm, fit.current!, state, model) + } else if (state.info !== undefined && !state.writable) xterm.resize(state.info.cols, state.info.rows) + }, [visible, state.writable, state.info?.cols, state.info?.rows, model]) + useEffect(() => { + terminal.current!.textarea?.setAttribute('aria-label', label) + }, [label]) + useEffect(() => { if (visible && state.writable) terminal.current!.focus() }, [visible, state.writable]) + return
+} +/* oxlint-enable typescript/no-non-null-assertion */ + +function fitScreen(xterm: Terminal, fit: FitAddon, state: TerminalViewState, model: TerminalView): void { + const dimensions = fit.proposeDimensions() + const environment = state.environment + if (dimensions === undefined || environment === undefined) return + const cols = Math.min(dimensions.cols, environment.maxCols) + const rows = Math.min(dimensions.rows, environment.maxRows) + if (cols < 2 || rows < 1) return + xterm.resize(cols, rows) + model.resize(cols, rows) +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css new file mode 100644 index 0000000000..c031726424 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css @@ -0,0 +1,3 @@ +.stack { position: fixed; right: 20px; bottom: 20px; display: grid; gap: 8px; max-width: min(440px, calc(100vw - 40px)); pointer-events: auto; } +.notice { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l1); box-shadow: 0 4px 20px #0002; font-size: 13px; overflow-wrap: anywhere; } +.notice button { flex-shrink: 0; padding: 4px 8px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 4px; color: inherit; background: transparent; cursor: pointer; } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx new file mode 100644 index 0000000000..97783a36a6 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx @@ -0,0 +1,29 @@ +/** Failed background cleanup remains actionable after the originating tab disappears. */ +import type { ReactNode } from 'react' +import type { TerminalCloseFailure } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from './locales.ts' +import css from './TerminalCleanup.module.css' + +/** The model owns cleanup; this seat only shows failures and requests retries. */ +export interface TerminalCleanupInjected { + readonly hooks: { readonly closeFailures: HostObservable } + /** @param id - failed cleanup identity. */ + readonly retryClose: (id: WebTerminalId) => void +} + +/** + * Render failed cleanup tasks without recreating or blocking any sidebar tab. + * @param props - root overlay hooks, retry command and localized copy. + * @returns a compact alert stack, empty when no close has failed. + */ +export function TerminalCleanup({ useCloseFailures, retryClose, t }: PropsRuntime<'shell.overlay'> & PropsLocale<'sidebarTerminal'> & InjectFace): ReactNode { + const failures = useCloseFailures(value => value) + if (failures.length === 0) return null + return
{failures.map(failure =>
+ {t('cleanupFailed', { title: failure.title, message: failure.message })} + +
)}
+} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx new file mode 100644 index 0000000000..fc9d995c10 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx @@ -0,0 +1,9 @@ +/** Terminal prompt glyph shared by the guide and tab title. */ +import type { ReactNode } from 'react' + +/** Render the terminal's decorative prompt icon. @returns a sixteen-pixel glyph. */ +export function TerminalIcon(): ReactNode { + return +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx new file mode 100644 index 0000000000..6557af4b3a --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx @@ -0,0 +1,29 @@ +/** A Session header lifetime restores retained Host terminals without saving sidebar layout. */ +import { useEffect, useState, type ReactNode } from 'react' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from './locales.ts' + +/** The plugin coordinates once-per-page recovery; the component only owns an error notice. */ +export interface TerminalRecoveryInjected { + /** @returns after retained terminals have been opened as sidebar tabs. */ + readonly restore: () => Promise +} + +/** + * Restore terminals when a Session is displayed, with a retry action on lookup failure. + * @param props - Session header lifetime, restoration callback and localized copy. + * @returns nothing on success, or an unobtrusive retry control. + */ +export function TerminalRecovery({ restore, t }: PropsRuntime<'conversation.session.header.actions'> & PropsLocale<'sidebarTerminal'> & TerminalRecoveryInjected): ReactNode { + const [error, setError] = useState() + const [attempt, setAttempt] = useState(0) + useEffect(() => { + let active = true + void restore().then(() => { if (active) setError(undefined) }, (reason: unknown) => { + if (active) setError(reason instanceof Error ? reason.message : String(reason)) + }) + return () => { active = false } + }, [restore, attempt]) + return error === undefined ? null : +} diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx new file mode 100644 index 0000000000..59cb1246b2 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx @@ -0,0 +1,60 @@ +/** Live terminal names in docked and floating tab chrome. */ +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { TerminalIcon } from './TerminalIcon.tsx' +import type { TerminalInjected } from './face.ts' +import type {} from './locales.ts' +import css from './TerminalBody.module.css' + +/** + * Render the terminal name, editable in place on double-click. + * @param props - sidebar occurrence, terminal model and localized copy. + * @returns the terminal icon and current name or its editor. + */ +export function TerminalTitle({ useTabInfo, useTerminal, view, t }: PropsRuntime<'sidebar.right.pane.tab.title'> & PropsLocale<'sidebarTerminal'> & InjectFace): ReactNode { + const { tab } = useTabInfo() + const title = useTerminal(tab.id, state => state?.info?.title ?? state?.title) ?? tab.title + const [editing, setEditing] = useState(false) + const input = useRef(null) + const label = useRef(null) + const cancelled = useRef(false) + useLayoutEffect(() => { + if (editing) return + // Dockkit captures the pointer on its drag handle, which receives the double-click. + const chip = label.current?.closest('[data-dockkit-tab], [data-dockkit-float-grip]') + const rename = (event: Event): void => { + event.stopPropagation() + cancelled.current = false + setEditing(true) + } + chip?.addEventListener('dblclick', rename) + return () => { chip?.removeEventListener('dblclick', rename) } + }, [editing]) + useLayoutEffect(() => { + if (!editing) return + input.current?.focus() + input.current?.select() + }, [editing]) + return <> + + {editing ? { event.stopPropagation() }} + onClick={(event) => { event.stopPropagation() }} + onDoubleClick={(event) => { event.stopPropagation() }} + onBlur={(event) => { + setEditing(false) + const next = event.currentTarget.value.trim() + if (!cancelled.current && next !== '' && next !== title) void view(tab.id).rename(next) + }} + onKeyDown={(event) => { + event.stopPropagation() + // oxlint-disable-next-line typescript/no-deprecated -- Some IMEs report composition only through keyCode 229. + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return + if (event.key === 'Escape') { + cancelled.current = true + event.currentTarget.blur() + } else if (event.key === 'Enter') event.currentTarget.blur() + }} /> + : {title}} + +} diff --git a/packages/client/ui-sidebar-terminal/src/client/face.ts b/packages/client/ui-sidebar-terminal/src/client/face.ts new file mode 100644 index 0000000000..95920e23b1 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/face.ts @@ -0,0 +1,19 @@ +/** Injected terminal commands and keyed observable state. */ +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type { TerminalView, TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' + +/** The terminal's React-free model is resolved by sidebar occurrence. */ +export interface TerminalInjected { + /** @param key - sidebar occurrence key. @returns its terminal commands. */ + readonly view: (key: string) => TerminalView + readonly keyedHooks: { readonly terminal: (key: string) => HostObservable } +} + + +declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' { + interface SidebarRightTabParamsMap { + /** An existing Host terminal selected from the Session terminal list. */ + terminal: { terminalId: WebTerminalId } + } +} diff --git a/packages/client/ui-sidebar-terminal/src/client/index.ts b/packages/client/ui-sidebar-terminal/src/client/index.ts new file mode 100644 index 0000000000..ce38b0feb0 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/index.ts @@ -0,0 +1,81 @@ +/** Register interactive terminal tabs and explicit process cleanup with the sidebar. */ +import type { Context } from '@deepseek-ai/cordis' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import type { TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-api-terminal-controller/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import { TerminalIcon } from './TerminalIcon.tsx' +import { TerminalBody } from './TerminalBody.tsx' +import { TerminalTitle } from './TerminalTitle.tsx' +import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx' +import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx' +import type { TerminalInjected } from './face.ts' +import { en, zh } from './locales.ts' + +/** Services needed by the terminal's two sidebar seats. */ +export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs', 'webTerminals'] + +/** + * Register the terminal type, observable views and background process cleanup. + * @param ctx - Client root Context with sidebar and terminal services. + */ +export function apply(ctx: Context): void { + let disposed = false + const recovered = new Map>() + ctx.effect(() => () => { disposed = true; recovered.clear() }, 'ui-sidebar-terminal.lifetime') + const terminalId = (sessionId: SessionId, key: string): WebTerminalId | undefined => { + const params = ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation.getSnapshot().params + return (params as { terminalId?: WebTerminalId } | undefined)?.terminalId + } + const view = (sessionId: SessionId, key: string) => ctx.webTerminals.view(sessionId, key, terminalId(sessionId, key)) + const namespace = 'sidebarTerminal' + const id = '@deepseek-ai/dsh-client-ui-sidebar-terminal' + const t = ctx.locale.bind(namespace) + ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-terminal.copy') + ctx.effect(() => ctx.sidebarRightTabs.register({ + id, kind: 'terminal', priority: 'builtin', title: () => t('title'), + guide: [{ order: 20, title: () => t('new'), icon: TerminalIcon, revealIfOpened: false }], + }), 'ui-sidebar-terminal.type') + ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => { + ctx.webTerminals.close(sessionId, tab.id, terminalId(sessionId, tab.id)) + }), 'ui-sidebar-terminal.close') + const inject = (sessionId: SessionId): TerminalInjected => ({ + view: key => view(sessionId, key), + keyedHooks: { terminal: key => view(sessionId, key).state }, + }) + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab', key: id, locale: namespace, inject }, TerminalBody, + )), 'ui-sidebar-terminal.body') + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register( + { name: 'sidebar.right.pane.tab.title', key: id, locale: namespace, inject }, TerminalTitle, + )), 'ui-sidebar-terminal.title') + ctx.effect(() => ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({ + name: 'conversation.session.header.actions', id, locale: namespace, + inject: (sessionId): TerminalRecoveryInjected => ({ + restore: () => { + let pending = recovered.get(sessionId) + if (pending === undefined) { + pending = ctx.webTerminals.recover(sessionId).then((terminals) => { + if (disposed) return + for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', { + revealIfOpened: false, params: { terminalId: info.id }, + }) + }).catch((error: unknown) => { recovered.delete(sessionId); throw error }) + recovered.set(sessionId, pending) + } + return pending + }, + }), + }, TerminalRecovery)), 'ui-sidebar-terminal.recovery') + ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id, locale: namespace, + inject: (): TerminalCleanupInjected => ({ + hooks: { closeFailures: ctx.webTerminals.closeFailures }, + retryClose: (terminalId) => { ctx.webTerminals.retryClose(terminalId) }, + }), + }, TerminalCleanup)), 'ui-sidebar-terminal.cleanup') +} diff --git a/packages/client/ui-sidebar-terminal/src/client/locales.ts b/packages/client/ui-sidebar-terminal/src/client/locales.ts new file mode 100644 index 0000000000..cdaad90ecc --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/client/locales.ts @@ -0,0 +1,30 @@ +/** Copy owned by the sidebar terminal feature. */ +import type {} from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + sidebarTerminal: keyof typeof zh + } +} + +/** Simplified Chinese terminal copy. */ +export const zh = { + recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端', + title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…', + connecting: '正在连接…', disconnected: '连接已断开。', reconnect: '重新连接', + readonly: '此页面当前只读。', control: '接管输入', + closed: '终端已关闭。', exited: '进程已退出({code})', failed: '终端错误:{message}', + rename: '终端名称', unavailable: '不可用', retry: '重试', + cleanupFailed: '终端「{title}」未能结束:{message}', +} satisfies Record + +/** English terminal copy. */ +export const en = { + recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery', + title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…', + connecting: 'Connecting…', disconnected: 'Disconnected.', reconnect: 'Reconnect', + readonly: 'This view is read-only.', control: 'Take control', + closed: 'Terminal closed.', exited: 'Process exited ({code})', failed: 'Terminal error: {message}', + rename: 'Terminal name', unavailable: 'Unavailable', retry: 'Retry', + cleanupFailed: 'Terminal “{title}” could not be ended: {message}', +} satisfies Record diff --git a/packages/client/ui-sidebar-terminal/src/css-modules.d.ts b/packages/client/ui-sidebar-terminal/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-sidebar-terminal/src/index.ts b/packages/client/ui-sidebar-terminal/src/index.ts new file mode 100644 index 0000000000..d7b0a4904f --- /dev/null +++ b/packages/client/ui-sidebar-terminal/src/index.ts @@ -0,0 +1,4 @@ +/** Host companion for the interactive terminal Client plugin. */ + +/** Mount the browser-only plugin through the Client loader. */ +export function apply(): void {} diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts new file mode 100644 index 0000000000..21f616a07d --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -0,0 +1,175 @@ +// @vitest-environment jsdom +/** Terminal type, copy, seats and explicit cleanup follow the plugin lifetime. */ +import { Context } from '@deepseek-ai/cordis' +import { expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WebTerminalId, WebTerminalInfo } from '@deepseek-ai/dsh-api-terminal-controller/types' +import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts' +import type { SidebarRightCloseHandler } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { apply, inject } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' +import { TerminalBody } from '../src/client/TerminalBody.tsx' +import { TerminalTitle } from '../src/client/TerminalTitle.tsx' +import { TerminalRecovery, type TerminalRecoveryInjected } from '../src/client/TerminalRecovery.tsx' +import { TerminalCleanup, type TerminalCleanupInjected } from '../src/client/TerminalCleanup.tsx' +import type { TerminalInjected } from '../src/client/face.ts' +import { en, zh } from '../src/client/locales.ts' + +vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn() })) + +const terminalInfo = (id: string): WebTerminalInfo => ({ id: id as WebTerminalId, title: id, shell: { path: '/bin/sh', name: 'sh', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null }) + +async function mountPlugin() { + const ctx = new Context() + const tabs = new SidebarRightTabRegistry(ctx) + ctx.provide('sidebarRightTabs', tabs) + const entries: { + name: string + key?: string + id?: string + locale: string + component: unknown + inject: (id: SessionId) => unknown + }[] = [] + const dictionaries = new Map() + let closeHandler: SidebarRightCloseHandler | undefined + const model = { state: {} } + const terminals = { + view: vi.fn(() => model), close: vi.fn(), closeFailures: {}, retryClose: vi.fn(), + recover: vi.fn(async (_sessionId: SessionId): Promise => []), + } + let params: { terminalId: WebTerminalId } | undefined + const occurrence = vi.fn(() => ({ navigation: { getSnapshot: () => ({ params }) } })) + const openTabIn = vi.fn() + ctx.provide('webTerminals', terminals as never) + ctx.provide('sidebarRight', { + tabDomain: { occurrence }, openTabIn, + registerCloseHandler: (kind: string, handler: SidebarRightCloseHandler) => { expect(kind).toBe('terminal'); closeHandler = handler; return () => { closeHandler = undefined } }, + } as never) + ctx.provide('slots', { + inject: (_name: string, register: () => () => void) => register(), + register: (options: Omit, component: unknown) => { const entry = { ...options, component }; entries.push(entry); return () => { entries.splice(entries.indexOf(entry), 1) } }, + } as never) + ctx.provide('locale', { + bind: () => (key: string) => key, + register: (name: string, values: unknown) => { dictionaries.set(name, values); return () => { dictionaries.delete(name) } }, + } as never) + const fiber = await ctx.plugin({ inject, apply }) + return { + tabs, entries, dictionaries, terminals, model, occurrence, openTabIn, + get closeHandler() { return closeHandler }, + setParams(next: typeof params) { params = next }, + async dispose() { await fiber.dispose(); await ctx.fiber.dispose() }, + } +} + +it('registers terminal views, recovery and cleanup, then releases every contribution on unload', async () => { + expect(hostApply).not.toThrow() + const h = await mountPlugin() + try { + const definition = h.tabs.get('terminal')! + expect(definition.title('sidebar://terminal')).toBe('title') + expect(definition.guide?.map(entry => [entry.order, entry.title(), entry.revealIfOpened])).toEqual([[20, 'new', false]]) + expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh }) + expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([ + ['sidebar.right.pane.tab', TerminalBody, 'sidebarTerminal'], + ['sidebar.right.pane.tab.title', TerminalTitle, 'sidebarTerminal'], + ['conversation.session.header.actions', TerminalRecovery, 'sidebarTerminal'], + ['shell.overlay', TerminalCleanup, 'sidebarTerminal'], + ]) + const sessionId = 'session' as SessionId + const face = h.entries[0]!.inject(sessionId) as TerminalInjected + expect(face.view('tab')).toBe(h.model) + expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', undefined) + const terminalId = 'retained' as WebTerminalId + h.setParams({ terminalId }) + expect(face.keyedHooks.terminal('tab')).toBe(h.model.state) + expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId) + expect(h.occurrence).toHaveBeenLastCalledWith(sessionId, { id: 'tab' }) + if (h.closeHandler === undefined) throw new Error('Terminal close handler was not registered') + expect(h.closeHandler(sessionId, { id: 'tab' } as Parameters[1])).toBeUndefined() + expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId) + h.setParams(undefined) + expect(h.closeHandler(sessionId, { id: 'new-tab' } as Parameters[1])).toBeUndefined() + expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'new-tab', undefined) + const cleanupFace = h.entries[3]!.inject(sessionId) as TerminalCleanupInjected + expect(cleanupFace.hooks.closeFailures).toBe(h.terminals.closeFailures) + cleanupFace.retryClose('terminal' as Parameters[0]) + expect(h.terminals.retryClose).toHaveBeenCalledWith('terminal') + } finally { + await h.dispose() + } + expect(h.closeHandler).toBeUndefined() + expect(h.tabs.get('terminal')).toBeUndefined() + expect(h.entries).toEqual([]) + expect(h.dictionaries.size).toBe(0) +}) + +it('shares pending and completed recovery across Session headers and opens each returned terminal once', async () => { + const h = await mountPlugin() + const pending = Promise.withResolvers() + h.terminals.recover.mockImplementationOnce(() => pending.promise) + const sessionId = 'session' as SessionId + const recovery = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected + const remounted = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected + const completion = recovery.restore() + try { + expect(remounted.restore()).toBe(completion) + expect(h.terminals.recover).toHaveBeenCalledExactlyOnceWith(sessionId) + expect(h.openTabIn).not.toHaveBeenCalled() + pending.resolve([terminalInfo('build'), terminalInfo('tests')]) + await completion + expect(h.openTabIn.mock.calls).toEqual([ + [sessionId, 'terminal', { revealIfOpened: false, params: { terminalId: 'build' } }], + [sessionId, 'terminal', { revealIfOpened: false, params: { terminalId: 'tests' } }], + ]) + await remounted.restore() + expect(h.terminals.recover).toHaveBeenCalledTimes(1) + expect(h.openTabIn).toHaveBeenCalledTimes(2) + const otherSession = 'other-session' as SessionId + await (h.entries[2]!.inject(otherSession) as TerminalRecoveryInjected).restore() + expect(h.terminals.recover).toHaveBeenLastCalledWith(otherSession) + expect(h.terminals.recover).toHaveBeenCalledTimes(2) + expect(h.openTabIn).toHaveBeenCalledTimes(2) + } finally { + pending.resolve([]) + await completion + await h.dispose() + } +}) + +it('allows a failed Host lookup to be retried without marking the Session recovered', async () => { + const h = await mountPlugin() + const unavailable = new Error('Host unavailable') + h.terminals.recover.mockRejectedValueOnce(unavailable) + const sessionId = 'session' as SessionId + const recovery = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected + try { + await expect(recovery.restore()).rejects.toBe(unavailable) + expect(h.openTabIn).not.toHaveBeenCalled() + await recovery.restore() + await recovery.restore() + expect(h.terminals.recover).toHaveBeenCalledTimes(2) + } finally { + await h.dispose() + } +}) + +it('does not open retained terminals when their lookup completes after plugin unload', async () => { + const h = await mountPlugin() + const pending = Promise.withResolvers() + h.terminals.recover.mockImplementationOnce(() => pending.promise) + const recovery = h.entries[2]!.inject('session' as SessionId) as TerminalRecoveryInjected + const completion = recovery.restore() + try { + expect(h.terminals.recover).toHaveBeenCalledOnce() + await h.dispose() + pending.resolve([terminalInfo('build')]) + await completion + expect(h.openTabIn).not.toHaveBeenCalled() + } finally { + pending.resolve([]) + await completion + await h.dispose() + } +}) diff --git a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx new file mode 100644 index 0000000000..fb1f61aab4 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +/** Cleanup errors remain reachable after the terminal tab has disappeared. */ +import { afterEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, within } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { TerminalCloseFailure } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import { TerminalCleanup } from '../src/client/TerminalCleanup.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +type CleanupProps = Parameters[0] +const unusedHook = (): never => { throw new Error('Terminal cleanup does not consume global hooks') } +const standard: Omit = { + useSessions: unusedHook, useSessionPendingInteraction: unusedHook, usePanelInfo: unusedHook, + useWorkspaces: unusedHook, useResource: unusedHook, +} + +it('stays absent for ordinary closes and offers a retry only after cleanup fails', () => { + let failures: readonly TerminalCloseFailure[] = [] + const retryClose = vi.fn() + const useCloseFailures: CleanupProps['useCloseFailures'] = selector => selector(failures) + const props: CleanupProps = { ...standard, useCloseFailures, retryClose, t: makeTranslate(en) } + const view = render() + expect(view.container.childElementCount).toBe(0) + failures = [{ id: 'terminal' as WebTerminalId, title: 'Build', message: 'Host unavailable' }] + view.rerender() + expect(view.getByRole('alert').textContent).toContain('Terminal “Build” could not be ended: Host unavailable') + fireEvent.click(view.getByRole('button', { name: en.retry })) + expect(retryClose).toHaveBeenCalledWith('terminal') + failures = [] + view.rerender() + expect(view.container.childElementCount).toBe(0) +}) + +it('keeps cleanup failures separate and retries the terminal selected by the user', () => { + const failures: readonly TerminalCloseFailure[] = [ + { id: 'build' as WebTerminalId, title: 'Build', message: 'Host unavailable' }, + { id: 'tests' as WebTerminalId, title: 'Tests', message: 'Process did not exit' }, + ] + const retryClose = vi.fn() + const useCloseFailures: CleanupProps['useCloseFailures'] = selector => selector(failures) + const props: CleanupProps = { ...standard, useCloseFailures, retryClose, t: makeTranslate(en) } + const view = render() + const notices = view.getAllByRole('alert') + expect(notices.map(notice => notice.textContent)).toEqual([ + 'Terminal “Build” could not be ended: Host unavailableRetry', + 'Terminal “Tests” could not be ended: Process did not exitRetry', + ]) + fireEvent.click(within(notices[1]!).getByRole('button', { name: en.retry })) + expect(retryClose).toHaveBeenCalledExactlyOnceWith('tests') +}) diff --git a/packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx new file mode 100644 index 0000000000..853ea8ff3e --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +/** Session terminal recovery hides ordinary work and keeps failed lookup retryable. */ +import { afterEach, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { TerminalRecovery } from '../src/client/TerminalRecovery.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +function props(restore: () => Promise): Parameters[0] { + // The renderer supplies Session shares; recovery needs only the injected callback and copy. + return { restore, t: makeTranslate(en) } as Parameters[0] +} + +it('restores on mount without adding controls during a successful lookup', async () => { + const pending = Promise.withResolvers() + const restore = vi.fn(() => pending.promise) + const view = render() + try { + expect(restore).toHaveBeenCalledOnce() + expect(view.container.childElementCount).toBe(0) + await act(async () => { pending.resolve(undefined); await pending.promise }) + view.rerender() + expect(restore).toHaveBeenCalledOnce() + expect(view.container.childElementCount).toBe(0) + } finally { + await act(async () => { pending.resolve(undefined); await pending.promise }) + } +}) + +it.each([new Error('Host unavailable'), 'Host unavailable'])('offers recovery retry for a failed lookup: %s', async (reason) => { + const pending = Promise.withResolvers() + const restore = vi.fn<() => Promise>().mockRejectedValueOnce(reason).mockImplementationOnce(() => pending.promise) + const view = render() + try { + const retry = await view.findByRole('button', { name: en.retryRecovery }) + expect(retry.getAttribute('title')).toBe('Terminal recovery failed: Host unavailable') + fireEvent.click(retry) + expect(restore).toHaveBeenCalledTimes(2) + expect(view.container.childElementCount).toBe(0) + await act(async () => { pending.resolve(undefined); await pending.promise }) + expect(view.container.childElementCount).toBe(0) + } finally { + await act(async () => { pending.resolve(undefined); await pending.promise }) + } +}) + +it('keeps the current Session failure when an earlier Session lookup later succeeds', async () => { + const pending = Promise.withResolvers() + const previous = vi.fn(() => pending.promise) + const current = vi.fn(async () => { throw new Error('Current Session failed') }) + const view = render() + try { + view.rerender() + const retry = await view.findByRole('button', { name: en.retryRecovery }) + expect(retry.getAttribute('title')).toContain('Current Session failed') + await act(async () => { pending.resolve(undefined); await pending.promise }) + expect(view.getByRole('button', { name: en.retryRecovery }).getAttribute('title')).toContain('Current Session failed') + } finally { + await act(async () => { pending.resolve(undefined); await pending.promise }) + } +}) + +it('ignores an earlier Session failure after a replacement Session recovers', async () => { + const pending = Promise.withResolvers() + const previous = vi.fn(() => pending.promise) + const completed = Promise.resolve() + const current = vi.fn(() => completed) + const view = render() + try { + view.rerender() + await act(async () => { await completed }) + await act(async () => { + pending.reject(new Error('Previous Session failed')) + await expect(pending.promise).rejects.toThrow('Previous Session failed') + }) + expect(view.container.childElementCount).toBe(0) + } finally { + pending.resolve(undefined) + await Promise.allSettled([pending.promise]) + } +}) diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx new file mode 100644 index 0000000000..8f89680527 --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx @@ -0,0 +1,299 @@ +// @vitest-environment jsdom +/** Terminal startup, title editing and xterm's screen lifetime. */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import type { ReactNode } from 'react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client' +import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' +import { TerminalBody, type TerminalBodyProps } from '../src/client/TerminalBody.tsx' +import { TerminalTitle } from '../src/client/TerminalTitle.tsx' +import { en, zh } from '../src/client/locales.ts' + +const fake = vi.hoisted(() => ({ + terminals: [] as FakeTerminal[], + dimensions: { cols: 120, rows: 40 } as { cols: number; rows: number } | undefined, +})) +class FakeTerminal { + options: { disableStdin?: boolean } + textarea: HTMLTextAreaElement | undefined = document.createElement('textarea') + input: ((data: string) => void) | undefined + readonly disposeInput = vi.fn() + readonly resize = vi.fn() + readonly reset = vi.fn() + readonly focus = vi.fn() + readonly dispose = vi.fn() + readonly write = vi.fn((_data: string, callback: () => void) => { callback() }) + readonly loadAddon = vi.fn() + constructor(options: object) { this.options = options; fake.terminals.push(this) } + open(node: HTMLElement) { node.appendChild(this.textarea!) } + onData(input: (data: string) => void) { this.input = input; return { dispose: this.disposeInput } } +} +vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn(function (options: object) { return new FakeTerminal(options) }) })) +vi.mock('@xterm/addon-fit', () => ({ FitAddon: class { proposeDimensions() { return fake.dimensions } } })) + +let measure: (() => void) | undefined +const disconnect = vi.fn() +let boxWidth = 800 +let boxHeight = 600 +beforeEach(() => { + fake.terminals.length = 0 + fake.dimensions = { cols: 120, rows: 40 } + boxWidth = 800 + boxHeight = 600 + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => boxWidth) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => boxHeight) + vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { measure = callback } observe() {} disconnect = disconnect }) +}) +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); disconnect.mockClear(); measure = undefined }) + +const info = { id: 'terminal' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running' as const, exitCode: null } +const environment = { cwd: '/workspace', maxCols: 200, maxRows: 100, scrollback: 1000, maxInputBytes: 1000 } +const idle: TerminalViewState = { phase: 'idle', writable: false, environment } +const titleSurfaces = [ + { + name: 'docked tab', selector: '[data-dockkit-tab]', + wrap: (title: ReactNode) =>
{title}
, + }, + { + name: 'floating grip', selector: '[data-dockkit-float-grip]', + wrap: (title: ReactNode) =>
{title}
, + }, +] +function mount(initial: TerminalViewState | undefined = idle) { + let state: TerminalViewState | undefined = initial + let visible = true + const detach = vi.fn() + const model = { + mount: vi.fn(() => detach), refresh: vi.fn(async () => {}), + rename: vi.fn(async () => {}), connect: vi.fn(), write: vi.fn(), resize: vi.fn(), acknowledge: vi.fn(), + } + const openTab = vi.fn() + const tab = () => ({ tab: { id: 'tab', title: 'Terminal', visible, actions: { openTab } } }) + // The test supplies the owner and model hooks consumed here; the remaining slot props are framework-owned. + const props = { + view: () => model, + useTerminal: (_key: string, select?: (value: TerminalViewState | undefined) => unknown) => select === undefined ? state : select(state), + useTabInfo: tab, t: makeTranslate(en), + } as unknown as TerminalBodyProps + const view = render() + return { + view, props, model, detach, openTab, + update(next: TerminalViewState | undefined, shown = visible) { + state = next; visible = shown; view.rerender() + }, + } +} + +it('mounts automatic startup and offers retry only when startup fails', () => { + const h = mount({ ...idle, phase: 'loading', environment: undefined }) + expect(h.model.mount).toHaveBeenCalledOnce() + expect(h.view.getByRole('status').textContent).toBe(en.loading) + expect(h.view.queryByRole('combobox')).toBeNull() + expect(h.view.queryByRole('button')).toBeNull() + h.update({ ...idle, phase: 'failed', error: 'unavailable' }) + expect(h.view.getByRole('alert').textContent).toContain('unavailable') + fireEvent.click(h.view.getByRole('button', { name: en.retry })) + expect(h.model.refresh).toHaveBeenCalledOnce() + h.update({ ...idle, phase: 'disconnected' }) + fireEvent.click(h.view.getByRole('button', { name: en.retry })) + expect(h.model.refresh).toHaveBeenCalledTimes(2) + h.update({ ...idle, phase: 'creating' }) + expect(h.view.getByRole('status').textContent).toBe(en.creating) + h.update({ ...idle, phase: 'closing' }) + expect(h.view.queryByRole('status')).toBeNull() + expect(h.view.queryByRole('button')).toBeNull() + h.update(undefined) + expect(h.view.container.childElementCount).toBe(0) + expect(h.model.mount).toHaveBeenCalledOnce() +}) + +it('shows only the terminal screen while connected and detaches on unmount', () => { + const h = mount({ ...idle, info, phase: 'connected', writable: true }) + expect(h.view.getByRole('textbox', { name: en.title })).toBeDefined() + expect(h.view.queryByRole('status')).toBeNull() + expect(h.view.queryByRole('button')).toBeNull() + expect(h.view.queryByRole('combobox')).toBeNull() + expect(h.view.queryByRole('heading')).toBeNull() + expect(h.view.queryByText('/workspace')).toBeNull() + expect(h.view.container.querySelector('header')).toBeNull() + h.view.unmount() + expect(h.detach).toHaveBeenCalledOnce() +}) + +it('keeps one emulator across rename and locale updates, applies snapshots and output once, and releases listeners', () => { + const snapshot: TerminalViewState = { ...idle, info, phase: 'connected', writable: true, render: { revision: 1, frame: { type: 'snapshot', sequence: 0, info, screen: 'restored' } } } + const h = mount(snapshot) + const terminal = fake.terminals[0]! + expect(terminal.reset).toHaveBeenCalledOnce() + expect(terminal.write).toHaveBeenCalledWith('restored', expect.any(Function)) + expect(h.model.acknowledge).toHaveBeenCalledWith(1) + expect(terminal.focus).toHaveBeenCalledOnce() + terminal.input?.('help\t') + expect(h.model.write).toHaveBeenCalledWith('help\t') + h.update({ ...snapshot, render: { revision: 2, frame: { type: 'output', sequence: 1, data: 'live' } } }) + expect(terminal.write).toHaveBeenCalledWith('live', expect.any(Function)) + h.update({ ...snapshot, render: { revision: 2, frame: { type: 'output', sequence: 1, data: 'duplicate' } } }) + expect(terminal.write).toHaveBeenCalledTimes(2) + h.update({ ...snapshot, info: { ...info, title: 'Development' } }) + h.view.rerender() + expect(fake.terminals).toHaveLength(1) + expect(terminal.textarea?.getAttribute('aria-label')).toBe(zh.title) + h.view.unmount() + expect(terminal.dispose).toHaveBeenCalledOnce() + expect(terminal.disposeInput).toHaveBeenCalledOnce() + expect(disconnect).toHaveBeenCalledOnce() +}) + +it('displays disconnect, close and exit states and offers explicit reconnection and takeover', () => { + const h = mount({ ...idle, info, phase: 'connecting' }) + expect(h.view.getByRole('status').textContent).toBe(en.connecting) + h.update({ ...idle, info, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toContain(en.readonly) + expect(fake.terminals[0]!.options.disableStdin).toBe(true) + fireEvent.click(h.view.getByRole('button', { name: en.control })) + expect(h.model.connect).toHaveBeenCalledOnce() + for (const phase of ['disconnected', 'failed'] as const) { + h.update({ ...idle, info, phase }) + fireEvent.click(h.view.getByRole('button', { name: en.reconnect })) + } + expect(h.model.connect).toHaveBeenCalledTimes(3) + h.update({ ...idle, info, phase: 'closing' }) + expect(h.view.queryByRole('status')).toBeNull() + h.update({ ...idle, info, phase: 'closed' }) + expect(h.view.getByRole('status').textContent).toBe(en.closed) + h.update({ ...idle, info: { ...info, state: 'exited', exitCode: 5 }, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toBe('Process exited (5)') + h.update({ ...idle, info: { ...info, state: 'exited', error: 'provider stopped' }, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toBe('Process exited (—)') + expect(h.view.getByRole('alert').textContent).toContain('provider stopped') + h.update({ ...idle, info: { ...info, state: 'failed', error: 'provider unreachable' }, phase: 'connected' }) + expect(h.view.getByRole('status').textContent).toBe(en.unavailable) +}) + +it('fits only visible writable terminals with measurable dimensions and clamps the provider limits', () => { + const state: TerminalViewState = { ...idle, info, phase: 'connected', writable: true } + const h = mount(state) + const terminal = fake.terminals[0]! + expect(h.model.resize).toHaveBeenCalledWith(120, 40) + h.model.resize.mockClear() + fake.dimensions = { cols: 500, rows: 300 } + measure?.() + expect(h.model.resize).toHaveBeenCalledWith(200, 100) + h.model.resize.mockClear() + h.update(state, false) + measure?.() + h.update({ ...state, writable: false }, true) + measure?.() + expect(h.model.resize).not.toHaveBeenCalled() + expect(terminal.resize).toHaveBeenLastCalledWith(80, 24) + boxWidth = 0 + h.update(state) + measure?.() + boxWidth = 800; boxHeight = 0 + measure?.() + h.update({ ...state, info: { ...info, cols: 81 } }) + expect(h.model.resize).not.toHaveBeenCalled() + boxHeight = 600 + fake.dimensions = undefined + measure?.() + h.update({ ...state, environment: undefined }) + fake.dimensions = { cols: 40, rows: 20 } + measure?.() + h.update(state) + for (const dimensions of [{ cols: 1, rows: 20 }, { cols: 40, rows: 0 }]) { fake.dimensions = dimensions; measure?.() } + expect(h.model.resize).not.toHaveBeenCalled() +}) + +it.each(titleSurfaces)('shows saved and attached terminal names in the $name', (surface) => { + const h = mount(idle) + const chip = () => surface.wrap() + const title = render(chip()) + expect(title.container.textContent).toBe('Terminal') + h.update({ ...idle, title: 'Saved terminal' }) + title.rerender(chip()) + expect(title.container.textContent).toBe('Saved terminal') + h.update({ ...idle, info }) + title.rerender(chip()) + expect(title.container.textContent).toBe('bash') + expect(title.container.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true') +}) + +it.each(titleSurfaces)('renames from the $name pointer target, preserving dragging and isolating editor shortcuts', (surface) => { + const h = mount({ ...idle, title: 'Saved terminal' }) + const outer = vi.fn() + const title = render(
+ {surface.wrap()} +
) + fireEvent.pointerDown(title.getByText('Saved terminal')) + expect(outer).toHaveBeenCalledOnce() + outer.mockClear() + fireEvent.doubleClick(title.container.querySelector(surface.selector)!) + const input = title.getByRole('textbox', { name: en.rename }) as HTMLInputElement + expect(document.activeElement).toBe(input) + expect(input.selectionStart).toBe(0) + expect(input.selectionEnd).toBe('Saved terminal'.length) + fireEvent.pointerDown(input) + fireEvent.click(input) + fireEvent.doubleClick(input) + fireEvent.keyDown(input, { key: 'ArrowLeft' }) + fireEvent.change(input, { target: { value: ' Development ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(h.model.rename).toHaveBeenCalledWith('Development') + expect(h.model.rename).toHaveBeenCalledOnce() + expect(title.queryByRole('textbox', { name: en.rename })).toBeNull() + expect(outer).not.toHaveBeenCalled() +}) + +it.each(titleSurfaces)('handles Escape, unchanged names and IME composition while editing the $name', (surface) => { + const h = mount({ ...idle, info }) + const title = render(surface.wrap()) + const edit = () => { + fireEvent.doubleClick(title.container.querySelector(surface.selector)!) + return title.getByRole('textbox', { name: en.rename }) + } + let input = edit() + fireEvent.change(input, { target: { value: 'Cancelled' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + expect(h.model.rename).not.toHaveBeenCalled() + input = edit() + fireEvent.blur(input) + expect(h.model.rename).not.toHaveBeenCalled() + input = edit() + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.blur(input) + expect(h.model.rename).not.toHaveBeenCalled() + input = edit() + fireEvent.change(input, { target: { value: '开发' } }) + fireEvent.keyDown(input, { key: 'Enter', isComposing: true }) + fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 }) + expect(title.getByRole('textbox', { name: en.rename })).toBe(input) + expect(h.model.rename).not.toHaveBeenCalled() + fireEvent.blur(input) + expect(h.model.rename).toHaveBeenCalledWith('开发') +}) + +it.each(titleSurfaces)('removes the native $name listener when its title unmounts', (surface) => { + const h = mount({ ...idle, info }) + const outer = vi.fn() + const seat = (shown: boolean) =>
+ {surface.wrap(shown && )} +
+ const title = render(seat(false)) + const chip = title.container.querySelector(surface.selector)! + const add = vi.spyOn(chip, 'addEventListener') + const remove = vi.spyOn(chip, 'removeEventListener') + title.rerender(seat(true)) + const listener = add.mock.calls.find(([type]) => type === 'dblclick')?.[1] + expect(listener).toBeTypeOf('function') + title.rerender(seat(false)) + expect(remove).toHaveBeenCalledExactlyOnceWith('dblclick', listener) + fireEvent.doubleClick(chip) + expect(outer).toHaveBeenCalledOnce() + expect(h.model.rename).not.toHaveBeenCalled() +}) + +it('starts a recovered screen with no local history when environment discovery is unavailable', () => { + mount({ ...idle, info, environment: undefined }) + expect(fake.terminals[0]!.options).toHaveProperty('scrollback', 0) +}) diff --git a/packages/client/ui-sidebar-terminal/tsconfig.json b/packages/client/ui-sidebar-terminal/tsconfig.json new file mode 100644 index 0000000000..4e229078ec --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tsconfig.json @@ -0,0 +1,51 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../core/session" + }, + { + "path": "../locale" + }, + { + "path": "../store" + }, + { + "path": "../ui-dockkit" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-session" + }, + { + "path": "../ui-sidebar-right" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../api/terminal-controller/tsconfig.client.json" + }, + { + "path": "../ui-layout" + }, + { + "path": "../ui-conversation" + } + ] +} diff --git a/packages/client/ui-sidebar-terminal/tsdown.config.ts b/packages/client/ui-sidebar-terminal/tsdown.config.ts new file mode 100644 index 0000000000..016643c5fb --- /dev/null +++ b/packages/client/ui-sidebar-terminal/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-terminal', ['lib/types/index.js']) diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index 36fde54534..aa75116ae8 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-e2b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/e2b/subprocess-e2b/README.md -README.md: 1c88e8b8650b93d326086f53311840922656244b -README.zh.md: 74c43ae98724e652618026c9eb3191998fbf52a9 +README.md: 11cb60e3966b2dd7bd20021a9cb6e3806b41df3e +README.zh.md: 14834748bbc82d87d3178ab75c7b470184b4e8c3 diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index 1c88e8b865..11cb60e396 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -125,6 +125,8 @@ Read these pages when the package-level contract is not enough. They move from t ----- +Terminal environment discovery reads the sandbox environment, and terminal resize uses the E2B PTY resize API. The caller-provided `terminalType` is applied to the remote child environment. Shell lookup misses are distinguished from transport errors. + ## Model Experience diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index 74c43ae987..14834748bb 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -125,6 +125,8 @@ agent 可以在沙箱中打开交互式终端、发送输入、读取输出, ----- +终端环境探测读取 sandbox 环境,终端 resize 使用 E2B PTY resize API。调用者指定的 `terminalType` 应用于远端子进程环境。shell 查找缺失与传输错误分别报告。 + ## 模型体验 diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index 62a8802d79..31d7e1bd54 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -9,18 +9,20 @@ import { posix } from 'node:path' import { inspect } from 'node:util' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, + SubprocessTerminalEnvironment, SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' -import { e2bControlEnvs, quoteE2BShellArg } from '@deepseek-ai/dsh-e2b' +import { CommandExitError, e2bControlEnvs, quoteE2BShellArg } from '@deepseek-ai/dsh-e2b' import { E2BSubprocessHandle } from './process.ts' import { asError, signalOpts } from './remote.ts' import { spawnE2BTerminal } from './terminal.ts' +import { readRemoteEnvironment, scrubRemoteEnvironment } from './environment.ts' /** Configuration for the E2B subprocess adapter. */ export interface Config { @@ -120,7 +122,7 @@ export class E2BSubprocessRuntime extends SubprocessRuntime { await sandbox.commands.run( `test -f ${quoteE2BShellArg(command)} -a -x ${quoteE2BShellArg(command)}`, { envs: e2bControlEnvs(), ...signalOpts(signal) }, - ) + ).catch((error: unknown) => { throw executableLookupError(command, error) }) signal?.throwIfAborted() return command } @@ -134,7 +136,7 @@ export class E2BSubprocessRuntime extends SubprocessRuntime { const result = await sandbox.commands.run( `${prefix}command -v -- ${quoteE2BShellArg(command)}`, { cwd: this.ctx.e2b.cwd, envs: e2bControlEnvs(), ...signalOpts(signal) }, - ) + ).catch((error: unknown) => { throw executableLookupError(command, error) }) signal?.throwIfAborted() const executable = result.stdout.trim() if (executable.includes('\n') || (!posix.isAbsolute(executable) && !executable.includes('/'))) { @@ -144,6 +146,14 @@ export class E2BSubprocessRuntime extends SubprocessRuntime { return posix.resolve(this.ctx.e2b.cwd, executable) } + /** @inheritdoc */ + async terminalEnvironment(signal?: AbortSignal): Promise { + const sandbox = await this.ctx.e2b.getSandbox() + const environment = scrubRemoteEnvironment(await readRemoteEnvironment(sandbox, signal)) + const defaultShell = environment.get('SHELL') + return { platform: 'posix', ...defaultShell === undefined ? {} : { defaultShell } } + } + /** @inheritdoc */ spawn(spec: SubprocessSpawnSpec): SubprocessHandle { if (this.disposing) throw new Error('subprocess-e2b: service is disposing') @@ -229,3 +239,10 @@ export class E2BSubprocessRuntime extends SubprocessRuntime { } export default E2BSubprocessRuntime + +function executableLookupError(command: string, error: unknown): unknown { + if (error instanceof CommandExitError && (error.exitCode === 1 || error.exitCode === 127)) { + return new SubprocessExecutableNotFoundError(`subprocess-e2b: executable ${JSON.stringify(command)} was not found`, { cause: error }) + } + return error +} diff --git a/packages/e2b/subprocess-e2b/src/terminal.ts b/packages/e2b/subprocess-e2b/src/terminal.ts index 29af8c29c5..1740e88077 100644 --- a/packages/e2b/subprocess-e2b/src/terminal.ts +++ b/packages/e2b/subprocess-e2b/src/terminal.ts @@ -310,6 +310,14 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle { }) } + /** @inheritdoc */ + resize(cols: number, rows: number): Promise { + return this.trackOperation(async (signal) => { + if (this.topLevelExited) throw new Error('terminal process has exited') + await this.sandbox.pty.resize(this.pid, { cols, rows }, { signal }) + }) + } + /** @inheritdoc */ inspectForeground(): Promise { return this.trackOperation(signal => this.inspectForegroundOnce(signal)) @@ -479,7 +487,7 @@ export async function spawnE2BTerminal( try { const ambient = await readRemoteEnvironment(sandbox, spec.signal) controlEnvs = bootstrapEnvironment(ambient) - const environment = serializeRemoteEnvironment(ambient, spec.env) + const environment = serializeRemoteEnvironment(ambient, { ...spec.env, TERM: spec.terminalType }) const argv = serializeValues(spec.argv, 'argv') stateDirectoryCreated = true await sandbox.files.makeDir(stateDir, signalOpts(spec.signal)) diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index c1f1a3e4d7..8e9938c9cc 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -464,7 +464,7 @@ describe('E2BSubprocessHandle', () => { for (const graceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { expect(() => service.spawn(spec({ graceMs }))).toThrow('graceMs must be a positive finite number') void expect(service.spawnTerminal({ - argv: ['bash'], cwd: '/w', rows: 24, cols: 80, graceMs, + argv: ['bash'], cwd: '/w', rows: 24, cols: 80, terminalType: 'dumb', graceMs, })).rejects.toThrow('graceMs must be a positive finite number') } }) diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index bc6118b0ff..e726269502 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -11,7 +11,7 @@ import { type Sandbox, } from '@deepseek-ai/dsh-e2b' import type E2BRuntime from '@deepseek-ai/dsh-e2b' -import type { SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { SubprocessExecutableNotFoundError, type SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import E2BSubprocessRuntime from '@deepseek-ai/dsh-subprocess-e2b' import { spawnE2BTerminal } from '../src/terminal.ts' @@ -85,6 +85,7 @@ class FakeTerminalSandbox { readonly commands: string[] = [] readonly commandOptions: CommandOptions[] = [] readonly inputs: Array<{ pid: number; data: Buffer }> = [] + readonly sizes: Array<{ pid: number; cols: number; rows: number }> = [] readonly removed: string[] = [] readonly directories: string[] = [] readonly writes = new Map() @@ -196,6 +197,10 @@ class FakeTerminalSandbox { }, }, pty: { + resize: async (pid: number, size: { cols: number; rows: number }, options?: { signal?: AbortSignal }): Promise => { + options?.signal?.throwIfAborted() + this.sizes.push({ pid, ...size }) + }, create: async (options: Parameters[0]): Promise => { this.createOptions = options if (this.createError !== undefined) throw this.createError @@ -240,6 +245,7 @@ function spec(overrides: Partial = {}): SubprocessT cwd: '/workspace', rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 5, env: { TERM: 'dumb', DSH_SESSION_ID: 'owner', TOKEN_EXPLICIT: 'kept' }, ...overrides, @@ -270,6 +276,21 @@ function testSpawn( } describe('E2B terminal allocation', () => { + it('preserves the requested terminal type and resizes the remote PTY only while running', async () => { + const fake = new FakeTerminalSandbox() + const terminal = await testSpawn(runtime(fake), spec({ terminalType: 'xterm-256color' }), '/runtime/interactive') + try { + expect(fake.writes.get('/runtime/interactive/environment')).toContain('TERM=xterm-256color\0') + await terminal.resize(132, 40) + expect(fake.sizes).toEqual([{ pid: terminal.pid, cols: 132, rows: 40 }]) + fake.handle.succeed() + await terminal.done + await expect(terminal.resize(80, 24)).rejects.toThrow('terminal process has exited') + } finally { + await terminal.terminate() + } + }) + it('hides bootstrap-shell bytes and preserves requested-shell bytes across the output boundary', async () => { const fake = new FakeTerminalSandbox() const terminal = await testSpawn(runtime(fake), spec(), '/runtime/terminal-one') @@ -813,6 +834,40 @@ describe('E2B subprocess terminal service', () => { await expect(ctx.subprocess.resolveExecutable('node')).rejects.toThrow('did not resolve') }) + it.each(['/missing/shell', 'missing-shell'])('classifies missing remote executable %s and preserves other failures', async (command) => { + const { ctx, fake, fiber } = await service() + try { + for (const exitCode of [1, 127]) { + const error = commandError(exitCode) + fake.commandFailure = error + await expect(ctx.subprocess.resolveExecutable(command)).rejects.toMatchObject({ + constructor: SubprocessExecutableNotFoundError, + cause: error, + }) + } + for (const error of [commandError(126), new Error('sandbox disconnected')]) { + fake.commandFailure = error + await expect(ctx.subprocess.resolveExecutable(command)).rejects.toBe(error) + } + } finally { + await fiber.dispose() + } + }) + + it('discovers terminal environment facts from the remote sandbox and propagates transport failures', async () => { + const { ctx, fake, fiber } = await service() + try { + fake.ambient = 'SHELL=/opt/fish\0' + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/opt/fish' }) + fake.ambient = 'PATH=/usr/bin\0' + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) + fake.commandFailure = new Error('sandbox disconnected') + await expect(ctx.subprocess.terminalEnvironment()).rejects.toThrow('sandbox disconnected') + } finally { + await fiber.dispose() + } + }) + it('rejects a non-positive poll cadence at load', async () => { const ctx = new Context() ctx.provide('e2b', runtime(new FakeTerminalSandbox())) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 0945d530e8..8d0c1d2a2e 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1112,6 +1112,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', 'client-ui-jobs JobListAction id \'job-list\'', 'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'', + 'client-ui-sidebar-terminal TerminalRecovery', 'experimental-client-ui-agent-team TeamAction id \'agent-team\'', ], replaceRisk: 'none', @@ -2032,7 +2033,9 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ hookContext: '', slotInject: '', declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted', - occupants: [], + occupants: [ + 'client-ui-sidebar-terminal TerminalCleanup', + ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-layout/src/client/index.ts:91', @@ -2263,6 +2266,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-sidebar-documentpreview TextPreview', 'client-ui-sidebar-files FilesBody', 'client-ui-sidebar-right GuideBody', + 'client-ui-sidebar-terminal TerminalBody', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', @@ -2308,6 +2312,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-sidebar-documentpreview TextTitle', 'client-ui-sidebar-files FilesTitle', 'client-ui-sidebar-right GuideTitle', + 'client-ui-sidebar-terminal TerminalTitle', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab.title\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab.title\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4606b99848..5b7b37ffd1 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2381,6 +2381,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'command', description: 'absolute executable path or bare PATH name.' }, { name: 'env', description: 'explicit environment entries used for lookup.' }, { name: 'signal', description: 'aborts remote or local lookup.' }], returns: 'a canonical executable path.', }, + { + signature: 'abstract terminalEnvironment(signal?: AbortSignal): Promise', + description: 'Inspect shell-selection facts in the provider\'s execution environment.', + parameters: [{ name: 'signal', description: 'cancellation of remote environment inspection.' }], + returns: 'platform and preferred shell; executable lookup and allocation remain separate operations.', + }, { signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle', description: 'Start one managed child process from a fully-specified spec; this seam applies no defaults.', @@ -2451,6 +2457,60 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'terminalController', + summary: 'Typed Remote control of transient Session-owned terminal processes.', + description: 'Typed Remote control of transient Session-owned terminal processes.', + methods: [ + { + signature: '@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment', + description: 'Read the Session working directory and terminal limits without resolving a shell.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'signal', description: 'request cancellation.' }], + returns: 'the Session workspace directory and terminal limits.', + }, + { + signature: '@Remote list(agent: Agent): WebTerminalInfo[]', + description: 'List running and exited terminals available for reattachment.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }], + returns: 'terminals retained for this Host lifetime.', + }, + { + signature: '@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise', + description: 'Allocate an interactive shell once for a caller-generated identity.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'request', description: 'initial dimensions and idempotency identity.' }, { name: 'signal', description: 'allocation cancellation; committed terminals survive disconnection.' }], + returns: 'the existing or newly committed terminal.', + }, + { + signature: '@Remote({ mode: \'stream\' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable', + description: 'Attach to a terminal without binding its process lifetime to the transport.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'attachmentId', description: 'new exclusive input attachment.' }, { name: 'signal', description: 'physical stream cancellation.' }], + returns: 'screen recovery followed by output and metadata changes.', + }, + { + signature: '@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise', + description: 'Deliver raw input, including Tab completion and control characters.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'attachmentId', description: 'current writable attachment.' }, { name: 'data', description: 'input bytes represented as UTF-8 text.' }], + returns: 'after provider input acceptance.', + }, + { + signature: '@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise', + description: 'Update the dimensions of the PTY and recovery screen.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'attachmentId', description: 'current writable attachment.' }, { name: 'cols', description: 'column count.' }, { name: 'rows', description: 'row count.' }], + returns: 'after the resize completes.', + }, + { + signature: '@Remote rename(agent: Agent, id: WebTerminalId, title: string): void', + description: 'Rename a terminal without changing its shell.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }, { name: 'title', description: 'nonempty display title, at most 120 characters.' }], + }, + { + signature: '@Remote async close(agent: Agent, id: WebTerminalId): Promise', + description: 'Close an identity to future creation and kill its process range; repeated closes succeed.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'id', description: 'terminal identity.' }], + returns: 'after provider cleanup succeeds. A failure retains the terminal for retry.', + }, + ], + }, { key: 'terminals', summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.', @@ -5824,13 +5884,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubprocessStdio', declaration: 'export interface SubprocessStdio {\n stdin: SubprocessStdinMode;\n stdout: SubprocessOutputMode;\n stderr: SubprocessOutputMode;\n}', }, + { + name: 'SubprocessTerminalEnvironment', + declaration: 'export interface SubprocessTerminalEnvironment {\n platform: \'posix\' | \'windows\';\n defaultShell?: string;\n}', + }, { name: 'SubprocessTerminalForeground', declaration: 'export interface SubprocessTerminalForeground {\n processGroupId: number;\n inputWaiting: boolean;\n}', }, { name: 'SubprocessTerminalHandle', - declaration: 'export interface SubprocessTerminalHandle {\n readonly pid: number;\n readonly output: Readable;\n readonly done: Promise;\n write(data: string): Promise;\n inspectForeground(): Promise;\n signalForeground(signal: SubprocessTerminalSignal): Promise;\n terminate(): Promise;\n}', + declaration: 'export interface SubprocessTerminalHandle {\n readonly pid: number;\n readonly output: Readable;\n readonly done: Promise;\n write(data: string): Promise;\n resize(cols: number, rows: number): Promise;\n inspectForeground(): Promise;\n signalForeground(signal: SubprocessTerminalSignal): Promise;\n terminate(): Promise;\n}', }, { name: 'SubprocessTerminalSignal', @@ -5838,7 +5902,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessTerminalSpawnSpec', - declaration: 'export interface SubprocessTerminalSpawnSpec {\n argv: readonly string[];\n cwd: string;\n env?: Record | undefined;\n rows: number;\n cols: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n}', + declaration: 'export interface SubprocessTerminalSpawnSpec {\n argv: readonly string[];\n cwd: string;\n env?: Record | undefined;\n rows: number;\n cols: number;\n terminalType: string;\n graceMs: number;\n signal?: AbortSignal | undefined;\n}', }, { name: 'SurfaceEvent', @@ -5920,6 +5984,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TeamWaitResult', declaration: 'export interface TeamWaitResult {\n readonly timedOut: boolean;\n}', }, + { + name: 'TerminalAttachmentId', + declaration: 'export type TerminalAttachmentId = Branded<\'TerminalAttachmentId\'>;', + }, { name: 'TerminalBackend', declaration: 'export interface TerminalBackend {\n readonly type: string;\n spawn(spec: TerminalBackendSpawnSpec): Promise;\n}', @@ -5936,6 +6004,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TerminalCallView', declaration: 'export interface TerminalCallView {\n card: \'terminal\';\n title: string;\n description?: string;\n cwd?: string;\n}', }, + { + name: 'TerminalCreateRequest', + declaration: 'export interface TerminalCreateRequest {\n readonly id: WebTerminalId;\n readonly cols: number;\n readonly rows: number;\n}', + }, + { + name: 'TerminalEnvironment', + declaration: 'export interface TerminalEnvironment {\n readonly cwd: string;\n readonly maxInputBytes: number;\n readonly maxCols: number;\n readonly maxRows: number;\n readonly scrollback: number;\n}', + }, + { + name: 'TerminalFrame', + declaration: 'export type TerminalFrame = {\n readonly type: \'snapshot\';\n readonly sequence: number;\n readonly screen: string;\n readonly info: WebTerminalInfo;\n} | {\n readonly type: \'output\';\n readonly sequence: number;\n readonly data: string;\n} | {\n readonly type: \'state\';\n readonly info: WebTerminalInfo;\n};', + }, { name: 'TerminalReadRequest', declaration: 'export interface TerminalReadRequest {\n offset?: number;\n count?: number;\n}', @@ -5980,6 +6060,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TerminalSessionStatus', declaration: 'export type TerminalSessionStatus = {\n kind: \'running\';\n} | {\n kind: \'exited\';\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n};', }, + { + name: 'TerminalShell', + declaration: 'export interface TerminalShell {\n readonly path: string;\n readonly args: readonly string[];\n readonly name: string;\n}', + }, { name: 'TerminalSignal', declaration: 'export type TerminalSignal = \'SIGINT\' | \'SIGTERM\' | \'SIGKILL\' | \'SIGTSTP\' | \'SIGHUP\';', @@ -6348,6 +6432,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WebSource', declaration: 'export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n}', }, + { + name: 'WebTerminalId', + declaration: 'export type WebTerminalId = Branded<\'WebTerminalId\'>;', + }, + { + name: 'WebTerminalInfo', + declaration: 'export interface WebTerminalInfo {\n readonly id: WebTerminalId;\n readonly title: string;\n readonly shell: TerminalShell;\n readonly cwd: string;\n readonly cols: number;\n readonly rows: number;\n readonly state: \'running\' | \'exited\' | \'failed\';\n readonly exitCode: number | null;\n readonly error?: string;\n readonly controllerId?: TerminalAttachmentId;\n}', + }, { name: 'WebUpgradeRoute', declaration: 'export interface WebUpgradeRoute {\n path: string;\n handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise;\n}', diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 3ecfd86a5b..d2582bcf55 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -146,6 +146,7 @@ class FakeHandle implements SubprocessHandle { * assert on the exact spawn specs and settled handles. */ class FakeSubprocess extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } spawns: SubprocessSpawnSpec[] = [] override async resolveExecutable(command: string): Promise { return command } override spawnTerminal(): Promise { throw new Error('search tools spawn pipes, never terminals') } diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 6f8a4f28b3..0a7b6834d9 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -188,6 +188,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => describe('spawn construction (pure, every platform)', () => { /** A subprocess service that records spawn specs and settles instantly. */ class CapturingSubprocessRuntime extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } specs: SubprocessSpawnSpec[] = [] done: Promise = Promise.resolve({ exitCode: 0, signal: null }) stderrText = '' diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 7979b68d4d..3cef0557d1 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md -README.md: 63d4d5d403d3559778dcad76a8c96cf677f1a977 -README.zh.md: f0a8b629b2ed17b6a6e9385a6eb565443c21ab26 +README.md: 11fd4cbcd4150cae75f15d5b5cc2d5294c2576ae +README.zh.md: 4bc0bc284770b68ef9cd8c6d09f39ccc8866d0f2 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 63d4d5d403..11fd4cbcd4 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -113,6 +113,8 @@ Read these pages when the provider-level contract is not enough. They move from ----- +Terminal allocation advertises the caller-provided `terminalType` through TERM and node-pty. Dynamic resize updates the existing PTY. Output backpressure pauses native reads until the consumer drains; explicit termination resumes a paused reader to receive the exit notification. + ## Model Experience diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index f0a8b629b2..4bc0bc2847 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -113,6 +113,8 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目 ----- +终端分配通过 TERM 和 node-pty 使用调用者指定的 `terminalType`。动态 resize 更新已有 PTY。输出背压会暂停原生读取,待消费者排空后恢复;显式终止会恢复暂停的读取,以接收退出通知。 + ## 模型体验 diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index beaeef58ba..85d80f4465 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -10,15 +10,17 @@ import { constants } from 'node:fs' import { access, stat } from 'node:fs/promises' +import { userInfo } from 'node:os' import { delimiter, extname, isAbsolute, resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import * as nodePty from 'node-pty' import type { IPtyForkOptions } from 'node-pty' -import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, + SubprocessTerminalEnvironment, SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import { @@ -147,7 +149,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { } } signal?.throwIfAborted() - throw new Error(absolute + throw new SubprocessExecutableNotFoundError(absolute ? `subprocess-local: command ${JSON.stringify(command)} is not an executable file` : `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`) } @@ -226,6 +228,15 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { ) } + /** @inheritdoc */ + // oxlint-disable-next-line typescript/require-await -- Keep the provider promise rejection semantics for cancelled inspection. + async terminalEnvironment(signal?: AbortSignal): Promise { + signal?.throwIfAborted() + const platform = process.platform === 'win32' ? 'windows' : 'posix' + const defaultShell = platform === 'windows' ? process.env.ComSpec : process.env.SHELL ?? userInfo().shell ?? undefined + return { platform, ...defaultShell === undefined ? {} : { defaultShell } } + } + // Local PTY allocation is synchronous, but the provider contract permits remote asynchronous allocation. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise { @@ -236,11 +247,11 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { spec.signal?.throwIfAborted() const env = targetEnvironment(spec) const options: IPtyForkOptions = { - name: 'dumb', + name: spec.terminalType, rows: spec.rows, cols: spec.cols, cwd: spec.cwd, - env, + env: { ...env, TERM: spec.terminalType }, } const inspector = this.terminalInspector ?? createProcessInspector() const containmentMode = this.selectContainmentMode('terminal') @@ -248,7 +259,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { ? prepareLinuxTerminalScope(spec, { ...env, PWD: spec.cwd, - TERM: 'dumb', + TERM: spec.terminalType, }) : undefined if (scope !== undefined) { diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 8d165dbd20..3f9f9c9811 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -65,6 +65,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private cleanup: Promise | undefined private managedOwnerCleaned = false private exited = false + private outputPaused = false private trackedDescendants: ProcessIdentity[] = [] /** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */ private readonly rootIdentity: ProcessIdentity | undefined @@ -86,7 +87,19 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { this.pid = terminal.pid this.rootIdentity = inspector.snapshot().tree(this.pid).find(member => member.pid === this.pid) this.done = this.outcome.promise - this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) }) + const resume = (): void => { + if (!this.outputPaused) return + this.outputPaused = false + if (!this.exited) terminal.resume() + } + this.output.on('drain', resume) + this.output.once('close', () => { this.output.off('drain', resume) }) + this.dataDisposable = terminal.onData((data) => { + if (!this.output.write(Buffer.from(data, 'utf8')) && this.cleanup === undefined && !this.outputPaused) { + this.outputPaused = true + terminal.pause() + } + }) this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => { if (this.exited) return this.exited = true @@ -115,6 +128,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { this.terminal.write(data) } + // oxlint-disable-next-line typescript/require-await -- Provider operations share promise rejection semantics. + async resize(cols: number, rows: number): Promise { + if (this.exited) throw new Error('terminal process has exited') + this.terminal.resize(cols, rows) + } + // Local inspection is synchronous; the seam returns a promise for remote transports. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async inspectForeground(): Promise { @@ -154,6 +173,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { terminate(): Promise { if (this.cleanup !== undefined) return this.cleanup + if (this.outputPaused) { this.outputPaused = false; this.terminal.resume() } const cleanup = this.closeOnce() this.cleanup = cleanup void cleanup.catch(() => { this.cleanup = undefined }) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts index 721c5134c3..080b503152 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -48,6 +48,7 @@ if (kind === 'ordinary') { cwd: process.cwd(), rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 30_000, }) } diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index a392d35d08..8c54ca20b1 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -537,6 +537,7 @@ describe('Linux PTY bootstrap reuse', () => { env: { TARGET: 'yes' }, rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 100, } as const diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 4ca9f64439..813f24d071 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -1,4 +1,6 @@ import { PassThrough } from 'node:stream' +import os from 'node:os' +import { syncBuiltinESMExports } from 'node:module' import { describe, expect, it, vi } from 'vitest' import { basename, dirname, relative, resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -40,6 +42,51 @@ function spec(command: string, overrides: Partial = {}): Su } describe('LocalSubprocessRuntime', () => { + it('discovers the platform shell without inventing a missing default and honors cancellation', async () => { + let loginShell: string | null = '/account/shell' + const userInfo = vi.spyOn(os, 'userInfo').mockImplementation(() => ({ + uid: 1, gid: 1, username: 'terminal-user', homedir: '/home/terminal-user', shell: loginShell, + })) + let fiber: Awaited> | undefined + let restorePlatform: (() => void) | undefined + try { + syncBuiltinESMExports() + const ctx = new Context() + fiber = await ctx.plugin(LocalSubprocessRuntime) + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + restorePlatform = () => { platform.mockRestore() } + vi.stubEnv('SHELL', '/environment/shell') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ + platform: 'posix', defaultShell: '/environment/shell', + }) + expect(userInfo).not.toHaveBeenCalled() + vi.stubEnv('SHELL', undefined) + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ + platform: 'posix', defaultShell: '/account/shell', + }) + loginShell = null + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) + platform.mockReturnValue('win32') + vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ + platform: 'windows', defaultShell: 'C:\\Windows\\System32\\cmd.exe', + }) + vi.stubEnv('ComSpec', undefined) + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'windows' }) + platform.mockReturnValue('linux') + userInfo.mockClear() + const reason = new Error('terminal inspection cancelled') + await expect(ctx.subprocess.terminalEnvironment(AbortSignal.abort(reason))).rejects.toBe(reason) + expect(userInfo).not.toHaveBeenCalled() + } finally { + restorePlatform?.() + vi.unstubAllEnvs() + userInfo.mockRestore() + syncBuiltinESMExports() + await fiber?.dispose() + } + }) + it('places the host-exit finalizer before listeners that predate the service', async () => { const baseline = new Set(process.listeners('exit')) const prior = vi.fn() @@ -223,7 +270,7 @@ describe('LocalSubprocessRuntime', () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) const base: SubprocessTerminalSpawnSpec = { - argv: ['bash'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10, + argv: ['bash'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 10, } await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [] })).rejects.toThrow('must contain a program') await expect(ctx.subprocess.spawnTerminal({ ...base, argv: [''] })).rejects.toThrow('must contain a program') @@ -240,6 +287,7 @@ describe('LocalSubprocessRuntime', () => { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => undefined, signalForeground: async () => 1, terminate, @@ -267,6 +315,7 @@ describe('LocalSubprocessRuntime', () => { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => undefined, signalForeground: async () => 1, terminate: vi.fn(async () => { throw firstFailure }), @@ -319,6 +368,7 @@ describe('LocalSubprocessRuntime', () => { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => undefined, signalForeground: async () => 1, terminate: vi.fn(async () => { throw failure }), @@ -394,7 +444,7 @@ describe('LocalSubprocessRuntime', () => { const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) const service = ctx.subprocess as InstanceType const handle = await ctx.subprocess.spawnTerminal({ - argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 1, }) expect((service as unknown as { terminals: Set }).terminals.size).toBe(1) exitListener?.({ exitCode: 0 }) @@ -486,6 +536,7 @@ describe('LocalSubprocessRuntime', () => { cwd: targetCwd, rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 10, env: { PWD: '/stale-parent-cwd', TERM: 'xterm-256color', TARGET_VALUE: 'preserved' }, }) @@ -566,7 +617,7 @@ describe('LocalSubprocessRuntime', () => { runtime.terminalInspector = inspector await expect(runtime.spawnTerminal({ - argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 10, + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 10, })).rejects.toBe(launchFailure) expect(cleanup).toHaveBeenCalledOnce() } finally { @@ -613,7 +664,7 @@ describe('LocalSubprocessRuntime', () => { signalProcess: () => {}, } const handle = await ctx.subprocess.spawnTerminal({ - argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, + argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'dumb', graceMs: 1, }) exitListener?.({ exitCode: 0 }) await handle.done diff --git a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts index 67bc46ecff..623d43cc01 100644 --- a/packages/subprocess/subprocess-local/tests/native-containment.spec.ts +++ b/packages/subprocess/subprocess-local/tests/native-containment.spec.ts @@ -228,6 +228,7 @@ while :; do sleep 60; done cwd: scratch, rows: 24, cols: 80, + terminalType: 'dumb', graceMs: 100, }) const output = captureTerminalOutput(handle) diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 7b02470f7c..bf34abdd79 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -13,6 +13,9 @@ import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' class FakePty { pid = 123 + readonly pause = vi.fn() + readonly resume = vi.fn() + readonly resize = vi.fn() readonly writes: string[] = [] readonly kills: string[] = [] autoExitOnKill = true @@ -114,6 +117,41 @@ function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): } describe('LocalTerminalHandle', () => { + it('pauses native output until the consumer drains and resumes before termination', async () => { + const pty = new FakePty() + const handle = makeHandle(pty, new FakeInspector(), 10) + const chunk = 'x'.repeat(handle.output.readableHighWaterMark + handle.output.writableHighWaterMark) + pty.emitData(chunk) + expect(pty.pause).toHaveBeenCalledOnce() + const chunks: Buffer[] = [] + handle.output.on('data', (data: Buffer) => { chunks.push(data) }) + await vi.waitFor(() => { expect(pty.resume).toHaveBeenCalledOnce() }) + expect(Buffer.concat(chunks).toString()).toBe(chunk) + handle.output.pause() + pty.emitData(chunk) + expect(pty.pause).toHaveBeenCalledTimes(2) + await handle.terminate() + handle.output.emit('drain') + expect(pty.resume).toHaveBeenCalledTimes(2) + handle.output.destroy() + }) + + it('does not resume native output when a pending drain follows the PTY exit', async () => { + const pty = new FakePty() + const handle = makeHandle(pty, new FakeInspector(), 10) + try { + pty.emitData('x'.repeat(handle.output.readableHighWaterMark + handle.output.writableHighWaterMark)) + expect(pty.pause).toHaveBeenCalledOnce() + pty.emitExit(0) + await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null }) + handle.output.emit('drain') + expect(pty.resume).not.toHaveBeenCalled() + } finally { + await handle.terminate() + handle.output.destroy() + } + }) + it('terminates a managed range with TERM when it stops within the grace period', async () => { vi.useFakeTimers() const pty = new FakePty() @@ -419,6 +457,8 @@ describe('LocalTerminalHandle', () => { pty.emitData('hello €') await handle.write('input\r') + await handle.resize(100, 30) + expect(pty.resize).toHaveBeenCalledWith(100, 30) expect(pty.writes).toEqual(['input\r']) expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true }) expect(inspector.stdinChecks).toEqual([[456, 123]]) @@ -427,6 +467,7 @@ describe('LocalTerminalHandle', () => { pty.emitExit(7, 9) pty.emitExit(0) + await expect(handle.resize(80, 24)).rejects.toThrow('terminal process has exited') expect(await handle.done).toEqual({ exitCode: null, signal: 'SIGKILL' }) await handle.terminate() expect(Buffer.concat(chunks).toString('utf8')).toBe('hello €') diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index c539bbce10..c2f88b1719 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess/README.md -README.md: 5b6ee4d3c1ab8a7b7071e40e845ad28a5213aa8b -README.zh.md: 6e0b4f2ca37e952d74701e6061c99fa941d81892 +README.md: d881878f4bc0acdcb585982c12a1caf106e44574 +README.zh.md: c10a1f4d3786b0746273429436b61a1c186ff561 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 5b6ee4d3c1..d881878f4b 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -123,6 +123,8 @@ Read these pages when the package-level contract is not enough. They move from t ----- +Terminal consumers use `terminalEnvironment()` to read the provider platform and preferred shell, and `resolveExecutable()` to verify candidates. A completed lookup miss throws `SubprocessExecutableNotFoundError`; transport failures remain distinct. `spawnTerminal` requires `terminalType` and initial dimensions, and its handle supports `resize(cols, rows)` without reallocating the process. + ## Model Experience diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index 6e0b4f2ca3..c10a1f4d37 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -123,6 +123,8 @@ spawn 会立即返回活动句柄,而不公开目标身份。`done` 独立报 ----- +终端消费者通过 `terminalEnvironment()` 读取 provider 平台和首选 shell,通过 `resolveExecutable()` 验证候选。确定未找到可执行文件时抛出 `SubprocessExecutableNotFoundError`,传输故障仍单独报告。`spawnTerminal` 要求 `terminalType` 和初始尺寸,返回的 handle 通过 `resize(cols, rows)` 调整尺寸,不重新分配进程。 + ## 模型体验 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 0cda343664..c914b48ede 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -12,7 +12,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { proxyEnvironmentForChild } from '@deepseek-ai/dsh-http-proxy' import { DSH_ENV_PREFIX } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' -import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' +import type { SubprocessTerminalEnvironment, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' export type { @@ -30,6 +30,7 @@ export type { SubprocessStdinMode, SubprocessStdio, SubprocessTerminalForeground, + SubprocessTerminalEnvironment, SubprocessTerminalHandle, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec, @@ -134,6 +135,13 @@ export abstract class SubprocessRuntime extends Service { signal?: AbortSignal, ): Promise + /** + * Inspect shell-selection facts in the provider's execution environment. + * @param signal - cancellation of remote environment inspection. + * @returns platform and preferred shell; executable lookup and allocation remain separate operations. + */ + abstract terminalEnvironment(signal?: AbortSignal): Promise + /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. @@ -154,3 +162,15 @@ export abstract class SubprocessRuntime extends Service { } export default SubprocessRuntime + +/** Executable lookup completed without finding an executable file. */ +export class SubprocessExecutableNotFoundError extends Error { + /** + * @param message - provider-specific lookup diagnostic. + * @param options - original provider failure, when available. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'SubprocessExecutableNotFoundError' + } +} diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 8f07e05fa9..809bd61064 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -198,6 +198,14 @@ export interface SubprocessHandle { */ export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP' +/** Shell-selection facts from the subprocess provider's execution environment. */ +export interface SubprocessTerminalEnvironment { + /** Operating-system family that interprets executable paths and shell arguments. */ + platform: 'posix' | 'windows' + /** Login or environment-selected shell, when the provider can resolve one. */ + defaultShell?: string +} + /** A fully specified terminal-process spawn. */ export interface SubprocessTerminalSpawnSpec { /** Executable and arguments; `argv[0]` is the program. */ @@ -210,6 +218,8 @@ export interface SubprocessTerminalSpawnSpec { rows: number /** Initial terminal column count. */ cols: number + /** Terminal emulation advertised to the child through TERM. */ + terminalType: string /** TERM-to-KILL cleanup grace for the complete terminal session. */ graceMs: number /** Cancellation of terminal allocation; a published handle owns its later lifetime. */ @@ -242,6 +252,12 @@ export interface SubprocessTerminalHandle { * @param data - text to deliver without implicit newline conversion. */ write(data: string): Promise + /** + * Change the terminal dimensions and notify its foreground application. + * @param cols - positive terminal column count. + * @param rows - positive terminal row count. + */ + resize(cols: number, rows: number): Promise /** * Inspect the current foreground process group. * @returns its id and input-wait fact, or undefined when no foreground group can be resolved. diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 1e71605781..7692c6519b 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -16,6 +16,7 @@ import type { * is all an implementation owes the abstract class. */ class StubSubprocessRuntime extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } async resolveExecutable(command: string): Promise { return `/bin/${command}` } @@ -42,6 +43,7 @@ class StubSubprocessRuntime extends SubprocessRuntime { output: new PassThrough(), done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => ({ processGroupId: 1, inputWaiting: true }), signalForeground: async () => 1, terminate: async () => {}, diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 9d38bc0c9e..741a0401e1 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -200,6 +200,7 @@ export class BashTerminalBackend implements TerminalBackend { env: childEnvironment(spec, this.config.shellDialect), rows: this.config.rows, cols: this.config.cols, + terminalType: 'dumb', graceMs: this.config.disposeGraceMs, signal: spec.signal, }) diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index af707d8269..629c82ffe7 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -72,6 +72,7 @@ function terminalHandle(): SubprocessTerminalHandle { output, done: Promise.resolve({ exitCode: 0, signal: null }), write: async () => {}, + resize: async () => {}, inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }), signalForeground: async () => 123, terminate: async () => { output.end() }, @@ -79,6 +80,7 @@ function terminalHandle(): SubprocessTerminalHandle { } class StubSubprocessRuntime extends SubprocessRuntime { + async terminalEnvironment() { return { platform: 'posix' as const } } async resolveExecutable(command: string): Promise { return command } spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { throw new Error('unused') } async spawnTerminal(_spec: SubprocessTerminalSpawnSpec): Promise { @@ -336,6 +338,7 @@ describe('BashTerminalBackend startup rollback', () => { output, done: outcome.promise, write: async () => {}, + resize: async () => {}, inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }), signalForeground: async () => 123, async terminate() { diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index b28c579375..d8051db3b6 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -86,6 +86,8 @@ class FakeTerminal implements SubprocessTerminalHandle { }) } + async resize(_cols: number, _rows: number): Promise {} + async write(data: string): Promise { if (this.throwWrite) throw new Error('write failed') this.writes.push(data) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcea481379..8f66333696 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -908,6 +908,9 @@ importers: '@deepseek-ai/dsh-api-settings-controller': specifier: workspace:^ version: link:../settings-controller + '@deepseek-ai/dsh-api-terminal-controller': + specifier: workspace:^ + version: link:../terminal-controller '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../workspace-controller @@ -1139,6 +1142,67 @@ importers: specifier: workspace:^ version: link:../../typert/protocol + packages/api/terminal-controller: + dependencies: + '@deepseek-ai/dsh-deque': + specifier: workspace:^ + version: link:../../util/deque + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + '@xterm/addon-serialize': + specifier: ^0.14.0 + version: 0.14.0 + '@xterm/headless': + specifier: ^6.0.0 + version: 6.0.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto + packages/api/workspace-controller: dependencies: '@deepseek-ai/dsh-deque': @@ -1759,6 +1823,9 @@ importers: '@deepseek-ai/dsh-api-settings-controller': specifier: workspace:^ version: link:../../api/settings-controller + '@deepseek-ai/dsh-api-terminal-controller': + specifier: workspace:^ + version: link:../../api/terminal-controller '@deepseek-ai/dsh-api-workspace-controller': specifier: workspace:^ version: link:../../api/workspace-controller @@ -1885,6 +1952,9 @@ importers: '@deepseek-ai/dsh-client-ui-sidebar-right': specifier: workspace:^ version: link:../../client/ui-sidebar-right + '@deepseek-ai/dsh-client-ui-sidebar-terminal': + specifier: workspace:^ + version: link:../../client/ui-sidebar-terminal '@deepseek-ai/dsh-client-ui-skill': specifier: workspace:^ version: link:../../client/ui-skill @@ -3973,6 +4043,78 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-sidebar-terminal: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-api-terminal-controller': + specifier: workspace:^ + version: link:../../api/terminal-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-dockkit': + specifier: workspace:^ + version: link:../ui-dockkit + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:^ + version: link:../ui-layout + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-sidebar-right': + specifier: workspace:^ + version: link:../ui-sidebar-right + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-skill: devDependencies: '@deepseek-ai/cordis': @@ -14407,9 +14549,18 @@ packages: resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} engines: {node: '>=10.0.0'} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/addon-serialize@0.14.0': + resolution: {integrity: sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==} + '@xterm/headless@6.0.0': resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + '@yao-pkg/pkg-fetch@3.6.4': resolution: {integrity: sha512-2JXvS9HbMudLlzEjSaJ7bLNnF/WlTv7iaTAJp2Tk8pBsgwCyL9p4rIN8cztHlyZF7qVzr1EaBjd7DQooFk0azQ==} hasBin: true @@ -21013,8 +21164,14 @@ snapshots: '@xmldom/xmldom@0.8.15': {} + '@xterm/addon-fit@0.11.0': {} + + '@xterm/addon-serialize@0.14.0': {} + '@xterm/headless@6.0.0': {} + '@xterm/xterm@6.0.0': {} + '@yao-pkg/pkg-fetch@3.6.4': dependencies: picocolors: 1.1.1 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a599410f68..2006a997c4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -125,6 +125,7 @@ export const SERVICE_PAGE: Record = { workspaceRegistry: 'workspace.md', workspaceController: 'workspace.md', workspaceFiles: 'workspace.md', + terminalController: 'workspace.md', directoryPickerController: 'workspace.md', } @@ -147,6 +148,7 @@ export const SERVICE_PAGE: Record = { * to a model as `cordis_runtime_inspect what:"client"`). */ export const SERVICE_WALK_EXEMPTIONS: Record = { + webTerminals: 'client-side terminal view models — packages/api/terminal-controller/README.md owns the API', appReady: 'not a service: launcher-provided successful-startup signal — packages/boot/cmdline/README.md owns the launcher contract', appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract', @@ -394,6 +396,7 @@ export const LINK_MAP: Readonly> = { SubprocessOutputReader: 'subprocess.md', SubprocessSpawnSpec: 'subprocess.md', SubprocessTerminalHandle: 'subprocess.md', + SubprocessTerminalEnvironment: 'subprocess.md', SubprocessTerminalSpawnSpec: 'subprocess.md', CodeRunRequest: 'code-runtime.md', CodeRunResult: 'code-runtime.md', @@ -771,6 +774,12 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { WorkspaceFileRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileStat: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileText: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + TerminalEnvironment: 'Browser terminal environment fields are owned by packages/api/terminal-controller/README.md', + WebTerminalInfo: 'Browser terminal metadata is owned by packages/api/terminal-controller/README.md', + TerminalCreateRequest: 'Browser terminal allocation fields are owned by packages/api/terminal-controller/README.md', + TerminalAttachmentId: 'Browser terminal input ownership is owned by packages/api/terminal-controller/README.md', + TerminalFrame: 'Browser terminal stream frames are owned by packages/api/terminal-controller/README.md', + WebTerminalId: 'Browser terminal identity is owned by packages/api/terminal-controller/README.md', } /** Repository data policy consumed by the Cordis catalog projector. */ diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 6c9ee96fb9..4f039af002 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -199,6 +199,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session\'s workspace root, confined by lstat, containment, and a stat re-check.', }, + { + key: 'terminalController', + pkg: 'api-terminal-controller', + title: 'Session interactive terminal Remote controller', + mode: 'core', + note: 'Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport.', + }, { key: 'workspaceController', pkg: 'api-workspace-controller', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 19be6bbc26..294bd11da5 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -44,6 +44,8 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { * blocks. A package moves on or off this list with its context behavior. */ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { + 'packages/api/terminal-controller': { kind: 'none', reason: 'User-owned terminal processes and screen streams never enter model requests or Session events.' }, + 'packages/client/ui-sidebar-terminal': { kind: 'none', reason: 'The browser renders user terminal screens without exposing them to the model.' }, 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 83cabd9e4a..09b3e76e05 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,6 +28,11 @@ // not declaration path aliases, keep each package/vendor source compiled // under its own tsconfig boundary. "paths": { + "@deepseek-ai/dsh-api-terminal-controller": ["./packages/api/terminal-controller/src/index.ts"], + "@deepseek-ai/dsh-api-terminal-controller/types": ["./packages/api/terminal-controller/src/types.ts"], + "@deepseek-ai/dsh-api-terminal-controller/client": ["./packages/api/terminal-controller/src/client/index.ts"], + "@deepseek-ai/dsh-client-ui-sidebar-terminal": ["./packages/client/ui-sidebar-terminal/src/index.ts"], + "@deepseek-ai/dsh-client-ui-sidebar-terminal/client": ["./packages/client/ui-sidebar-terminal/src/client/index.ts"], "@deepseek-ai/cordis": ["./vendor/cordis/src"], "@deepseek-ai/cosmokit": ["./vendor/cosmokit/src"], "@deepseek-ai/schemastery": ["./vendor/schemastery/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 8ffb94127a..b831138e97 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -41,6 +41,8 @@ "packages/client/*/tests/**/*.host.spec.ts" ], "references": [ + { "path": "./packages/client/ui-sidebar-terminal" }, + { "path": "./packages/api/terminal-controller/tsconfig.client.json" }, // Producer-to-UI spill tests execute the real nested dispatch worker. { "path": "./packages/code-runtime/code-runtime-worker-thread" }, // Shared leaf: web e2e boots the real host webserver (fixture + real-host diff --git a/tsconfig.host.json b/tsconfig.host.json index c5a3f118b3..bbc7885490 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -96,6 +96,7 @@ "apps/web/tests/goal-multi-turn-actions.e2e.ts", "apps/web/tests/agent-team-panel.e2e.ts", "apps/web/tests/sidebar-right.e2e.ts", + "apps/web/tests/sidebar-terminal.e2e.ts", "apps/web/tests/chat-scroll-fixture.ts", "apps/web/tests/chat-scroll-contract.e2e.ts", "apps/web/tests/chat-long-interactions.e2e.ts", @@ -133,6 +134,7 @@ "scripts/client-bundle-purity.spec.ts" ], "references": [ + { "path": "./packages/api/terminal-controller/tsconfig.host.json" }, { "path": "./packages/client/ui-deliverables/tsconfig.host.json" }, { "path": "./vendor/cosmokit" }, { "path": "./vendor/schemastery" }, From 819c2b2442c8025534747b28eeb3ae3501da4d2a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 16:47:16 +0800 Subject: [PATCH 04/38] feat(client): show expanded JSON strings as raw text --- .../ui-primitives/src/JsonTree.module.css | 38 ++- .../client/ui-primitives/src/JsonTree.tsx | 233 +++++++++++------- 2 files changed, 175 insertions(+), 96 deletions(-) diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index 59ed4ba033..536e7bc052 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -109,6 +109,8 @@ } .stringField { + position: relative; + z-index: 1; display: flow-root; } @@ -121,9 +123,31 @@ word-break: break-all; } -.stringField[data-expanded='true'] > .stringText { - max-height: none; - overflow: visible; +.stringRaw { + margin: 4px 0 0 12px; + overflow: auto; + color: var(--dsw-alias-label-primary); + font: inherit; + overflow-wrap: normal; + white-space: pre; + word-break: normal; +} + +.stringActions { + position: absolute; + top: 1px; + right: 4px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + width: fit-content; + height: 16px; +} + +.stringActions > .copySlot { + opacity: 1; + pointer-events: auto; } .stringToggleSlot { @@ -152,10 +176,6 @@ pointer-events: auto; } -.stringCollapse { - margin-left: 4px; -} - .stringToggle:hover { color: var(--dsw-alias-label-primary); } @@ -228,10 +248,6 @@ padding-right: 24px; } -.row:has(> .copySlot) > .stringField .stringCollapse { - margin-right: 24px; -} - .row:hover:not(:has(.row:hover)) > .copySlot, .row[data-json-copy-active] > .copySlot, .copySlot:focus-within { diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 064e8c323e..10a97dfd2a 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -226,7 +226,7 @@ interface JsonTreeNodeProps { onClaimTabStop: (id: string) => void onRowHover: (row: HTMLElement, target: RowTarget) => void path: JsonPath - renderCopy: ((path: JsonPath) => ReactNode) | undefined + renderCopy: ((target: RowTarget, persistent?: boolean) => ReactNode) | undefined tabStopId: string | null value: unknown } @@ -236,20 +236,24 @@ function JsonString({ field, labels, lastElement, + renderCopy, value, }: { collapsedStringLines: number field: string | undefined labels: JsonTreeLabels lastElement: boolean + renderCopy: ((persistent?: boolean) => ReactNode) | undefined value: string }) { const contentsId = useId() const contentRef = useRef(null) + const rawRef = useRef(null) const [expanded, setExpanded] = useState(false) const [truncated, setTruncated] = useState(false) useLayoutEffect(() => { + if (expanded) return const content = contentRef.current as HTMLSpanElement const measure = () => { const lineHeight = Number.parseFloat(getComputedStyle(content).lineHeight) @@ -260,32 +264,60 @@ function JsonString({ const observer = new ResizeObserver(measure) observer.observe(content) return () => { observer.disconnect() } - }, [collapsedStringLines, field, lastElement, value]) + }, [collapsedStringLines, expanded, field, lastElement, value]) - return ( - - - {truncated && !expanded && ( - - - - )} - {field !== undefined && {fieldText(field)}:} - {primitiveValue(value)} - {!lastElement && ,} - {truncated && expanded && ( + useLayoutEffect(() => { + if (!expanded) return + const raw = rawRef.current as HTMLPreElement + const clips: HTMLElement[] = [] + const tree = raw.closest(`.${css.root}`) as HTMLElement + for (let parent = tree.parentElement; parent !== null; parent = parent.parentElement) { + if (/auto|scroll|hidden|clip/.test(getComputedStyle(parent).overflowY)) clips.push(parent) + } + const measure = () => { + let top = 0 + let bottom = window.innerHeight + for (const clip of clips) { + const rect = clip.getBoundingClientRect() + const style = getComputedStyle(clip) + top = Math.max(top, rect.top + clip.clientTop) + bottom = Math.min(bottom, rect.top + clip.clientTop + clip.clientHeight + - Number.parseFloat(style.paddingBottom)) + } + const available = bottom - Math.max(top, raw.getBoundingClientRect().top) + raw.style.maxHeight = `${Math.max(16, available - 4)}px` + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(raw) + for (const clip of clips) observer.observe(clip) + window.addEventListener('resize', measure) + window.addEventListener('scroll', measure, true) + return () => { + observer.disconnect() + window.removeEventListener('resize', measure) + window.removeEventListener('scroll', measure, true) + } + }, [expanded, value]) + + if (expanded) { + const fieldId = `${contentsId}-field` + return ( +
+ {field !== undefined && {fieldText(field)}:} +
+          {value}
+        
+
- )} + {renderCopy?.(true)} +
+
+ ) + } + + return ( + <> + {renderCopy?.()} + + + {truncated && ( + + + + )} + {field !== undefined && {fieldText(field)}:} + {primitiveValue(value)} + {!lastElement && ,} + -
+ ) } @@ -347,7 +407,7 @@ function JsonTreeNode({ onRowHover(event.currentTarget, { path, value }) }} > - {renderCopy?.(path)} + {typeof value !== 'string' && renderCopy?.({ path, value })} {children}
) @@ -360,6 +420,7 @@ function JsonTreeNode({ value={value} labels={labels} lastElement={lastElement} + renderCopy={renderCopy === undefined ? undefined : persistent => renderCopy({ path, value }, persistent)} />, ) } @@ -494,7 +555,7 @@ export function JsonTree({ : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]]) : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null const activeRowRef = useRef() - const copyButtonRef = useRef(null) + const copyButtonRef = useRef(null) const copyMenuOpenRef = useRef(false) const resetTimer = useRef>() const [copyTarget, setCopyTarget] = useState() @@ -548,11 +609,10 @@ export function JsonTree({ if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget() } - const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => { - /* v8 ignore next -- copy controls only render while their target exists. */ - if (copyTarget === undefined) return + const copy = async (target: RowTarget, mode: 'json' | 'path' | 'prettyJson' | 'value') => { + setCopyTarget(target) try { - await navigator.clipboard.writeText(copyText(copyTarget, mode)) + await navigator.clipboard.writeText(copyText(target, mode)) setCopyState('copied') } catch { setCopyState('failed') @@ -562,58 +622,61 @@ export function JsonTree({ } const [rootOpen, rootClose] = bracketOf(data) - const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null - const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value' - const copyTitle = copyState === 'copied' - ? labels.copied - : copyState === 'failed' - ? labels.copyFailed - : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue - - const renderCopy = copyable ? (path: JsonPath) => ( - - {copyTarget !== undefined && pathId(copyTarget.path) === pathId(path) && ( - void copy(defaultCopyMode)} - onContextMenu={(event) => { - event.preventDefault() - event.stopPropagation() - copyMenuOpenRef.current = true - setCopyMenuOpen(true) - }} - > - {copyState === 'copied' - ? - : } - - )} - items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} - onSelect={(id) => { - void copy(id as 'json' | 'path' | 'prettyJson' | 'value') - copyMenuOpenRef.current = false - setCopyMenuOpen(false) - }} - onClose={clearCopyTarget} - getAnchorRect={() => ( - copyButtonRef.current as HTMLButtonElement - ).getBoundingClientRect()} - /> - )} - - ) : undefined + const renderCopy = copyable ? (target: RowTarget, persistent = false) => { + const active = copyTarget !== undefined && pathId(copyTarget.path) === pathId(target.path) + const state = active ? copyState : 'idle' + const object = typeof target.value === 'object' && target.value !== null + const copyTitle = state === 'copied' + ? labels.copied + : state === 'failed' + ? labels.copyFailed + : object ? labels.copyPrettyJson : labels.copyValue + return ( + + {(persistent || active) && ( + void copy(target, object ? 'prettyJson' : 'value')} + onContextMenu={(event) => { + event.preventDefault() + event.stopPropagation() + copyButtonRef.current = event.currentTarget + setCopyTarget(target) + copyMenuOpenRef.current = true + setCopyMenuOpen(true) + }} + > + {state === 'copied' + ? + : } + + )} + items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} + onSelect={(id) => { + void copy(target, id as 'json' | 'path' | 'prettyJson' | 'value') + copyMenuOpenRef.current = false + setCopyMenuOpen(false) + }} + onClose={clearCopyTarget} + getAnchorRect={() => ( + copyButtonRef.current as HTMLButtonElement + ).getBoundingClientRect()} + /> + )} + + ) + } : undefined return (
- {renderCopy?.([])} + {renderCopy?.({ path: [], value: data })} {rootOpen}
Date: Wed, 9 Sep 2026 17:10:44 +0800 Subject: [PATCH 05/38] fix(client): restore assistant timing from recorded streams --- ...6-embedded-stream-record-readers.i18n.yaml | 4 +-- ...26-09-06-embedded-stream-record-readers.md | 2 +- ...09-06-embedded-stream-record-readers.zh.md | 2 +- apps/web/tests/navigation-panes.e2e.ts | 25 ++++++++++++++++--- apps/web/tests/turn-tail-actions.e2e.ts | 4 +-- packages/client/ui-chat/README.i18n.yaml | 4 +-- packages/client/ui-chat/README.md | 2 ++ packages/client/ui-chat/README.zh.md | 2 ++ .../client/conversation-nodes/assistant.ts | 23 +++++++++++++++-- ...nversation-node-definitions.client.spec.ts | 11 ++++++-- .../client/ui-trajectory/README.i18n.yaml | 4 +-- packages/client/ui-trajectory/README.md | 2 ++ packages/client/ui-trajectory/README.zh.md | 2 ++ .../client/trajectory-assistant-definition.ts | 21 ++++++++++++++-- .../conversation-definitions.client.spec.ts | 11 ++++++-- .../web/navigation-panes/timing.expected.md | 12 +++++++++ 16 files changed, 110 insertions(+), 21 deletions(-) create mode 100644 snapshots/web/navigation-panes/timing.expected.md diff --git a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml index e78d7bdc51..a5e163f76e 100644 --- a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md -2026-09-06-embedded-stream-record-readers.md: 972fee634833cef5fd7b0a54f69780b9370f0cc3 -2026-09-06-embedded-stream-record-readers.zh.md: faba6e179887a9943926f2c73aa8e42a10cee300 +2026-09-06-embedded-stream-record-readers.md: 76e109de577d070093bb7123aa50d9a4ae7fcbe5 +2026-09-06-embedded-stream-record-readers.zh.md: 33f4b31117197c47e7f2ff1637bc13fa2cbf1a8c diff --git a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md index 972fee6348..76e109de57 100644 --- a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md +++ b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md @@ -20,7 +20,7 @@ After v2 embedded streams settlement widened with the message content and Chat a - Run readers: `runFirstTokenTime` and `runFirstVisibleTime` reconstruct the first qualifying member's time from `time0` and the `dt` gaps and stop scanning there; a name-bearing Tool-call run yields `time0` without reading a fragment. - Stream readers: `assistantStreamFirstTokenTime`, `assistantStreamHasVisibleContent`, `assistantStreamHasVisibleText`, `lastAssistantStreamChunk(stream, type)` (backward scan), `assistantStreamChunks(stream, type)`, `joinAssistantStreamText`, and `assembleAssistantStream`, which feeds a `BlockAssembler` one joined delta per run (assembly only concatenates, so blocks, usage, finish, and replay state equal the per-member result). `RawStreamChunkType` excludes the delta types, so a raw-chunk lookup can never silently skip packed members. -Session Stats reads `assistantStreamFirstTokenTime`; the token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images. +Session Stats, Chat, and Trajectory read `assistantStreamFirstTokenTime` from both `assistant/attempt` and `assistant/message`, retaining the Step's first token across retries. Chat and Trajectory settle content from the assembled message while reading timing independently, so reopening history retains TTFT and decoding metrics without expanding streams. The token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images. `expandAssistantStream` keeps its strict validation and its remaining callers, which need every member or validate the stream at a durable boundary: Session restore validation, the v1-to-v2 migration validator and publication Worker replay, the reconnect baseline, and test support. diff --git a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md index faba6e1798..33f4b31117 100644 --- a/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md @@ -20,7 +20,7 @@ Session 格式 v2 将每次模型尝试的紧凑流(`AssistantStreamRecord[]` - Run 读取器:`runFirstTokenTime` 与 `runFirstVisibleTime` 从 `time0` 与 `dt` 间隔重建首个合格成员的时间并停止扫描;带名称的 Tool-call run 直接产出 `time0`,不读片段。 - 流读取器:`assistantStreamFirstTokenTime`、`assistantStreamHasVisibleContent`、`assistantStreamHasVisibleText`、`lastAssistantStreamChunk(stream, type)`(逆向扫描)、`assistantStreamChunks(stream, type)`、`joinAssistantStreamText` 与 `assembleAssistantStream`(每个 run 向 `BlockAssembler` 喂入一个拼接后的 delta;组装只做拼接,因此 blocks、usage、finish 与 replay state 与逐成员结果一致)。`RawStreamChunkType` 排除 delta 类型,因此原始 chunk 查找不可能静默跳过打包成员。 -Session Stats 读取 `assistantStreamFirstTokenTime`;token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。 +Session Stats、Chat 与 Trajectory 从 `assistant/attempt` 和 `assistant/message` 读取 `assistantStreamFirstTokenTime`,跨重试保留步骤的首个 token。Chat 与 Trajectory 从组装后的消息结算内容,并独立读取计时,因此重新打开历史时无需展开流便能保留 TTFT 与解码指标。token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。 `expandAssistantStream` 保留其严格校验与其余调用方(需要每个成员或在持久边界校验流):Session 恢复校验、v1-to-v2 迁移校验器与发布 Worker 重放、重连基线、测试支撑。 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index d51e08bb49..e99568cb1f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -24,6 +24,7 @@ import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './supp const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') +const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md') const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md') const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const MODE = webSnapshotMode() @@ -97,7 +98,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts') .toEqual([PROMPT_TURN1, PROMPT_TURN2]) - await seedSession(scaffold, raw, SEED_ID) + // The inspector's calendar date must not depend on the day the test runs. + await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: Date.UTC(2026, 0, 1) }) } browser = await chromium.launch() }, 120_000) @@ -264,13 +266,30 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) await page.getByRole('tab', { name: 'Result' }).click() await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) - expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBe(0) + expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBeGreaterThan(0) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) await details.getByRole('button', { name: 'Close details' }).click() }, 60_000) + it.skipIf(MODE === 'record')('restores Assistant timing from recorded history', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timing')) + await ensureSeedOpen(page) + await page.getByRole('tab', { name: 'Trajectory' }).click() + await page.getByRole('button', { name: 'Request #1', exact: true }).click() + const details = page.getByRole('complementary', { name: 'Event details' }) + await details.getByRole('tab', { name: 'Timing', exact: true }).click() + const panel = details.getByRole('tabpanel', { name: 'Timing' }) + for (const metric of ['TTFT', 'Generation', 'Throughput']) { + const value = panel.getByText(metric, { exact: true }).locator('..').locator('dd') + await expect.poll(() => value.textContent()).toMatch(/^\d/) + } + expect(await panel.getByText('First token unavailable', { exact: true }).count()).toBe(0) + const snapshot = await captureStableAria(page, '#trajectory-detail-panel', scaffold.workspaceCwd) + await compareOrRefreshGolden(TIMING_EXPECTED, snapshot, MODE) + }, 60_000) + it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export')) await ensureSeedOpen(page) @@ -505,7 +524,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md', - 'terminal-card.expected.md', + 'terminal-card.expected.md', 'timing.expected.md', ]) }) }) diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 0a176a9af0..fdc1ef26f5 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -201,8 +201,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await timeTrigger.click() const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' }) expect(await timeDialog.count()).toBe(1) - expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0) - expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0) + expect(await timeDialog.getByText(/tok\/s/).count()).toBeGreaterThan(0) + expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(1) await page.keyboard.press('Escape') await trigger.click() diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4db27677d0..ffbb411643 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8 -README.zh.md: 4dd06a16b19b14cacc0ded603cc1cf8869dd1a43 +README.md: 3e99cd599a834a9629193015e9d76468bf6480f4 +README.zh.md: d431cf567dda8d78ef2c696959ae2574feaa49d3 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 860dcb9eeb..3e99cd599a 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -34,6 +34,8 @@ Each nonempty appended `system/message` owns a collapsed prompt row, including a A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total. +Reply timing survives reopening history: recorded token timestamps supply first-token latency and generation duration, including the Step's first token from an earlier retry attempt. Missing Step starts or token timestamps leave the affected metrics unavailable. + ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 4dd06a16b1..d431cf567d 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -36,6 +36,8 @@ kind: "package-reference" 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成 Turn 才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。 +重新打开历史后仍保留回复计时:记录中的 token 时间戳提供首 token 延迟和生成耗时,包括步骤中更早重试尝试的首个 token。缺少步骤起点或 token 时间戳时,受影响的指标保持不可用。 + ----- diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index 41093124a8..aecfa43c5e 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -4,6 +4,7 @@ import type { ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm/assistant-stream' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { AssistantChatData } from '../contract/chat-nodes.ts' @@ -161,6 +162,17 @@ function updateChunk( } } +/** The Step retains its first token across live chunks and settled retry attempts. */ +function settleTiming( + state: AssistantState, + event: SessionEvent<'assistant/message' | 'assistant/attempt'>, +): AssistantState { + return { + ...state, + firstTokenTime: state.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream), + } +} + function settleMessage( state: AssistantState, match: ConversationMatch, @@ -168,7 +180,7 @@ function settleMessage( ): AssistantState { const blocks = toAssistantBlocks(event.data.message.content) return { - ...state, + ...settleTiming(state, event), blocks, visibleBlocks: countVisibleBlocks(blocks), hidden: false, @@ -236,6 +248,11 @@ function fallbackState(context: ConversationNodeContext): Assist state = updateChunk(state, match.event.data.chunk, match.event.seq, match.event.time) continue } + if (match.event.type === 'assistant/attempt') { + state ??= initialState(match.event.data.turn, match.event.data.step) + state = settleTiming(state, match.event) + continue + } if (match.event.type === 'assistant/message') { state ??= initialState(match.event.data.turn, match.event.data.step) state = settleMessage(state, match, match.event) @@ -296,6 +313,7 @@ export const assistantDefinition: ConversationNodeDefinition = { match: (event) => { if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } if (event.type === 'assistant/live-chunk' + || event.type === 'assistant/attempt' || (event.type === 'assistant/message' && event.surfaceOp === 'append')) { return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } } @@ -313,13 +331,14 @@ export const assistantDefinition: ConversationNodeDefinition = { return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time) } if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event) + if (match.event.type === 'assistant/attempt') return settleTiming(context.state, match.event) if (match.event.type === 'llm/retry') { return resetForRetry(context.state) } return context.state }, publication: (match) => { - if (match.event.type === 'step/start') return 'none' + if (match.event.type === 'step/start' || match.event.type === 'assistant/attempt') return 'none' if (match.event.type !== 'assistant/live-chunk') return 'immediate' const type = match.event.data.chunk.type return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 85fa2b6eb0..5134b2193c 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1056,9 +1056,16 @@ describe('built-in conversation node Definitions', () => { const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode expect(finalNode).toMatchObject({ blocks: [{ kind: 'text', text: 'done' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 1_999 }, }) + const windowed = assembler(finalizedInputs.slice(2), true) + const timing = () => (node(snapshot(windowed), 'assistant-step')?.data as AssistantChatData).finalNode?.timing + expect(timing()).toMatchObject({ stepStartTime: null, firstTokenTime: 1_999 }) + windowed.prepend(finalizedInputs.slice(0, 2), false) + windowed.flush() + expect(timing()).toEqual(finalNode?.timing) + const namedToolHistory = [ at(40, 'turn/start', { turn: 3 }), at(41, 'step/start', { turn: 3, step: 1 }), @@ -1083,7 +1090,7 @@ describe('built-in conversation node Definitions', () => { const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode expect(namedTool).toMatchObject({ blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 4_000 }, }) }) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 8f91bd2d8b..741beb718f 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 2b8ecff50b498be529ccf7e35e528f7554084940 -README.zh.md: 8463bd0a68c32753138425d968db229726b5bd1a +README.md: 099ce8c9330040e106ab4ab1bfbf155d32a38e79 +README.zh.md: 7b00cd8ccf8dfdc36f6df55e3b0e7c1ae8fe5847 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 2b8ecff50b..099ce8c933 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -33,6 +33,8 @@ Selection, timeline navigation, folding, and search cover the React-visible wind ### The timing overview +Historical replies retain TTFT, generation duration, and throughput when their recorded streams contain token timestamps. TTFT measures from the Step start to its first token, including output from an earlier retry attempt; an unloaded Step start or a stream without tokens leaves the corresponding metric unavailable. + A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range; wheel gestures zoom the time domain; a right-button click clears the selected interval, and a right-button drag pans an already zoomed viewport. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. ----- diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 8463bd0a68..7b00cd8ccf 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -33,6 +33,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 ### 时间概览 +当记录的流包含 token 时间戳时,历史回复会保留 TTFT、生成耗时与吞吐量。TTFT 从步骤开始计量到首个 token,包括更早重试尝试中的输出;步骤起点尚未加载或流中没有 token 时,对应指标保持不可用。 + 固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻与耗时详情。拖选区间会把记录表聚焦到该闭区间内任何时刻处于活动的记录;滚轮手势用于缩放时间域;右键单击会清除所选区间,在已放大的视口上按住右键拖动则会平移。初始视图与流式更新都停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。 ----- diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 2bc68039d6..e7d4706be0 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -5,6 +5,7 @@ import type { PartialAssistant, RequestView, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm/assistant-stream' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { trajectoryNode } from './trajectory-definition-common.ts' import { @@ -191,6 +192,17 @@ function updateChunk( } } +/** The Step retains its first token across live chunks and settled retry attempts. */ +function settleTiming( + state: AssistantState, + event: SessionEvent<'assistant/message' | 'assistant/attempt'>, +): AssistantState { + return { + ...state, + firstTokenTime: state.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream), + } +} + function settleMessage( state: AssistantState, match: ConversationMatch, @@ -198,7 +210,7 @@ function settleMessage( ): AssistantState { const blocks = toAssistantBlocks(event.data.message.content) return { - ...state, + ...settleTiming(state, event), sawChunk: false, blocks, visibleBlocks: countVisibleBlocks(blocks), @@ -226,6 +238,9 @@ function fallbackState(context: ConversationNodeContext): Assist if (event.type === 'assistant/live-chunk') { state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) state = updateChunk(state, event.data.chunk, event.seq, event.time) + } else if (event.type === 'assistant/attempt') { + state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) + state = settleTiming(state, event) } else if (event.type === 'assistant/message') { state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) state = settleMessage(state, match, event) @@ -325,6 +340,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition } if (event.type === 'assistant/live-chunk' || event.type === 'assistant/message' + || event.type === 'assistant/attempt' || event.type === 'llm/retry' || event.type === 'step/end') { return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } @@ -348,6 +364,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time) } if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event) + if (match.event.type === 'assistant/attempt') return settleTiming(context.state, match.event) if (match.event.type === 'step/end') return { ...context.state, stepEnd: match } if (match.event.type !== 'llm/retry') return context.state const data = match.event.data @@ -372,7 +389,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition } }, publication: (match) => { - if (match.event.type === 'step/start') return 'none' + if (match.event.type === 'step/start' || match.event.type === 'assistant/attempt') return 'none' if (match.event.type !== 'assistant/live-chunk') return 'immediate' const type = match.event.data.chunk.type return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index abcf09646f..3ccd5fdd9d 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -335,13 +335,20 @@ describe('Trajectory conversation Definitions', () => { const finalizedPacked = snapshot(assembler(finalizedInputs)) expect(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({ blocks: [{ kind: 'text', text: 'done' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 3_000 }, }) expect(finalizedPacked.requests).toMatchObject([{ purpose: 'assistant', retry: 1, }]) + const windowed = assembler(finalizedInputs.slice(2), true) + const assistant = () => snapshot(windowed).eventNodes.find(node => node.kind === 'assistant') + expect(assistant()).toMatchObject({ timing: { stepStartTime: null, firstTokenTime: 3_000 } }) + windowed.prepend(finalizedInputs.slice(0, 2), false) + windowed.flush() + expect(assistant()).toEqual(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')) + const namedToolHistory = [ at(40, 'turn/start', { turn: 3 }), at(41, 'step/start', { turn: 3, step: 1 }), @@ -365,7 +372,7 @@ describe('Trajectory conversation Definitions', () => { const namedToolPacked = snapshot(assembler(namedToolInputs)) expect(namedToolPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({ blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }], - timing: { firstTokenTime: null }, + timing: { firstTokenTime: 4_000 }, }) }) diff --git a/snapshots/web/navigation-panes/timing.expected.md b/snapshots/web/navigation-panes/timing.expected.md new file mode 100644 index 0000000000..0a7a17c389 --- /dev/null +++ b/snapshots/web/navigation-panes/timing.expected.md @@ -0,0 +1,12 @@ +- tabpanel "Timing": + - term: Started + - definition: + - button "2026-01-01 {{clock}}" + - term: Total duration + - definition: {{duration}} + - term: TTFT + - definition: {{duration}} + - term: Generation + - definition: {{duration}} + - term: Throughput + - definition: {{throughput}} tok/s From ac09b7b51980219614ba3300064a064c27b8a980 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:16:15 +0800 Subject: [PATCH 06/38] fix(trajectory): narrow Chinese role column --- .../ui-trajectory/src/client/TrajectoryTable.module.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 35266eed00..410842358a 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -137,6 +137,10 @@ width: 122px; } +.eventColumn:where(:lang(zh)) { + width: 84px; +} + .contentColumn { width: auto; } @@ -448,6 +452,10 @@ width: 76px; } +.kindSlot:where(:lang(zh)) { + width: 44px; +} + .kindSlot :global([role='tooltip']) { border: 0; background: var(--dsw-alias-bg-layer-2); From e4f0871b80b2d0218c94e207aa87d71be720a149 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:19:03 +0800 Subject: [PATCH 07/38] fix(trajectory): clarify overview overflow and tighten spacing --- .../src/client/TrajectoryTable.module.css | 22 ++++++++++++------- .../src/client/TrajectoryTable.tsx | 20 +++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 410842358a..414e123d9e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1046,6 +1046,7 @@ .detailBodySummary > .overview { flex: 0 1 auto; min-height: 0; + padding-bottom: 0; overflow: auto; overscroll-behavior: contain; } @@ -1184,13 +1185,13 @@ display: flex; flex: 1 1 0; max-height: max-content; - min-height: 28px; + min-height: 24px; flex-direction: column; overflow: hidden; } .overviewSection + .overviewSection { - padding-top: 8px; + padding-top: 2px; } .overviewHeading { @@ -1199,7 +1200,7 @@ align-items: flex-end; box-sizing: border-box; width: 100%; - height: 28px; + height: 24px; margin: 0; padding: 0 0 3px 14px; color: var(--dsw-alias-label-secondary); @@ -1251,6 +1252,10 @@ background: var(--dsw-alias-bg-layer-1); } +.overviewPreview[data-scroll-more] { + mask-image: linear-gradient(to bottom, #000 calc(100% - 12px), transparent); +} + .overviewPreview > :last-child { margin-bottom: 0; padding-bottom: 0; @@ -1258,6 +1263,7 @@ .overviewPreview .jsonPreview > :first-child, .overviewPreview .schemaTree > :first-child { + padding-top: 2px; padding-bottom: 0; } @@ -1413,7 +1419,7 @@ } .markdownPreview { - padding: 6px 14px 8px; + padding: 2px 14px 8px; } .markdownPayload { @@ -1443,7 +1449,7 @@ } .schemaPreview .schemaIntro { - padding-top: 6px; + padding-top: 2px; } .schemaName { @@ -1490,7 +1496,7 @@ .payloadPreview { min-height: 0; - padding: 6px 14px 8px; + padding: 2px 14px 8px; } .sourceBlocks { @@ -1741,7 +1747,7 @@ .resultBlocksPreview { min-height: 0; - padding: 6px 14px 8px; + padding: 2px 14px 8px; gap: 6px; } @@ -1756,7 +1762,7 @@ } .overviewPreview .noPayload { - padding: 6px 14px 8px; + padding: 2px 14px 0; } .noPayload { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index b517578e80..1ac861ffee 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1787,6 +1787,23 @@ function OverviewSection({ onOpen: () => void children: ReactNode }) { + const previewRef = useRef(null) + const [hasMore, setHasMore] = useState(false) + const measureOverflow = useCallback((preview: HTMLElement) => { + setHasMore(preview.scrollHeight - preview.clientHeight - preview.scrollTop > 1) + }, []) + + useLayoutEffect(() => { + const preview = previewRef.current as HTMLDivElement + const measure = () => { measureOverflow(preview) } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(preview) + for (const child of preview.children) observer.observe(child) + return () => { observer.disconnect() } + }, [children, measureOverflow]) + return (

@@ -1800,8 +1817,11 @@ function OverviewSection({

{ measureOverflow(event.currentTarget) }} > {children}
From 1549483397c82551a2d28e2be3e78aabc494aa77 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:19:10 +0800 Subject: [PATCH 08/38] feat(client): expand reasoning-only assistant output by default --- .../ui-chat/src/client/chat/AssistantMarkdown.tsx | 9 ++++++++- .../client/ui-chat/src/client/chat/ReasoningRow.tsx | 13 ++++++++++--- .../ui-trajectory/src/client/TrajectoryTable.tsx | 13 ++++++++++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 3212801e00..51300f6d02 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -58,6 +58,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ return { resolve: value => localPathMediaUrl(protocol, origin, value) } }, []) const last = blocks.length - 1 + const hasBodyOrToolCall = blocks.some(block => block.kind === 'tool-call' + || (block.kind === 'text' && block.text.trim() !== '')) // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root // between tool groups — skip the shell unless something visible remains. @@ -89,7 +91,12 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ hidden={reasoningHidden} reveal={revealProcess} > - + , ) break diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index bc7859b364..bb22eea778 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -22,11 +22,18 @@ function latestLine(text: string): string { * the complete text. * @param props.text - complete or streaming reasoning text. * @param props.running - whether this block is the streaming tail. + * @param props.defaultExpanded - expanded state until the reader toggles it. * @param props.t - conversation locale seat for the running status. * @returns the reasoning disclosure. */ -export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) { - const [expanded, setExpanded] = useState(false) +export function ReasoningRow({ text, running, defaultExpanded, t }: { + text: string + running: boolean + defaultExpanded: boolean + t: ChatViewSlotProps['t'] +}) { + const [expandedOverride, setExpandedOverride] = useState() + const expanded = expandedOverride ?? defaultExpanded const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '') return ( @@ -47,7 +54,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool open={expanded} expandable expandOnRowClick - onToggle={() => { setExpanded(value => !value) }} + onToggle={() => { setExpandedOverride(!expanded) }} collapsedContent={( <> diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 1ac861ffee..a47c17212e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1863,7 +1863,7 @@ export function TrajectoryTable({ const [selectedRecordId, setSelectedRecordId] = useState(null) const [selectedRequest, setSelectedRequest] = useState(null) const [activeTab, setActiveTab] = useState('overview') - const [thinkingExpanded, setThinkingExpanded] = useState(false) + const [thinkingDisclosure, setThinkingDisclosure] = useState<{ recordId: string; expanded: boolean }>() const [detailsWidth, setDetailsWidth] = useState(null) const [toolRequestOffset, setToolRequestOffset] = useState(null) const detailsResizeDrag = useRef(null) @@ -1895,6 +1895,17 @@ export function TrajectoryTable({ const selected = selectedTemplate === undefined ? undefined : currentRecord(selectedTemplate) + const thinkingExpanded = thinkingDisclosure?.recordId === selectedRecordId + ? thinkingDisclosure.expanded + : selected?.cell.kind === 'message' + && Boolean(selected.cell.thinkingDetail?.trim()) + && !selected.cell.outputDetail?.trim() + && !selected.cell.sourceBlocks?.some(block => block.type === 'tool-call') + const setThinkingExpanded = (expanded: boolean) => { + if (selectedRecordId !== null) { + setThinkingDisclosure({ recordId: selectedRecordId, expanded }) + } + } const selectedIndex = selected?.cell.index ?? null useEffect(() => { onSelectedIndexChange?.(selectedIndex) From c6fb28a16468c6e36e8c5d36ccaf9d1e2afff5f9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:19:18 +0800 Subject: [PATCH 09/38] fix(client): use a collapse icon for expanded JSON strings --- packages/client/ui-primitives/src/JsonTree.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 10a97dfd2a..b5f0d7a5e8 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -317,13 +317,24 @@ function JsonString({
{renderCopy?.(true)}
From 00b16cb63ae571093ef54183cbc1ce026476666c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 9 Sep 2026 17:29:00 +0800 Subject: [PATCH 10/38] fix(web): align terminal preview, styles, and snapshots --- packages/api/terminal-controller/README.i18n.yaml | 4 ++-- packages/api/terminal-controller/README.md | 2 +- packages/api/terminal-controller/README.zh.md | 2 +- packages/api/terminal-controller/src/terminal.ts | 13 ++++++------- .../api/terminal-controller/tests/terminal.spec.ts | 8 ++++++++ .../src/client/TerminalBody.module.css | 4 ++-- .../src/client/TerminalCleanup.module.css | 4 ++-- .../session/cordis-inspect-jsdoc/session.v3.jsonl | 2 +- 8 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index 2b60551cd2..eba346c0ea 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md -README.md: f6755cab8def6bc1f5842ee387a20c5ed51e7e7f -README.zh.md: fef6e6b45cef87e0a3f1c433dec13baad59ab75a +README.md: 08def8e53f6e9362b2bd99f03dc552115dcf9539 +README.zh.md: 4cd9d42272e4ff6a588305056450d145dc7dbb12 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index f6755cab8d..08def8e53f 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -45,7 +45,7 @@ New terminals use the execution environment's declared default shell. Only when
Implementation internals — click to expand -The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. +The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index fef6e6b45c..4cd9d42272 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -45,7 +45,7 @@ Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway
实现细节 -Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 +Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 diff --git a/packages/api/terminal-controller/src/terminal.ts b/packages/api/terminal-controller/src/terminal.ts index 103426cf72..90688d9d78 100644 --- a/packages/api/terminal-controller/src/terminal.ts +++ b/packages/api/terminal-controller/src/terminal.ts @@ -1,15 +1,14 @@ /** One PTY, a bounded terminal emulator and its detachable browser followers. */ import { createRequire } from 'node:module' -import { StringDecoder } from 'node:string_decoder' import type { Terminal as HeadlessTerminal } from '@xterm/headless' import type { SerializeAddon as Serializer } from '@xterm/addon-serialize' import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import { TerminalFollower } from './stream.ts' import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts' -const require = createRequire(import.meta.url) -const { Terminal } = require('@xterm/headless') as typeof import('@xterm/headless') -const { SerializeAddon } = require('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize') +// Literal createRequire calls keep CommonJS xterm entries reachable in the Preview image. +const { Terminal } = createRequire(import.meta.url)('@xterm/headless') as typeof import('@xterm/headless') +const { SerializeAddon } = createRequire(import.meta.url)('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize') /** Process lifetime is independent of follower and component lifetimes. */ export class BrowserTerminal { @@ -141,15 +140,15 @@ export class BrowserTerminal { } private async consume(): Promise { - const decoder = new StringDecoder('utf8') + const decoder = new TextDecoder('utf-8', { ignoreBOM: true }) const outcome = this.handle.done.then(value => ({ value }), (error: unknown) => ({ error })) try { for await (const chunk of this.handle.output) { // Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks. - const data = decoder.write(chunk as Buffer) + const data = decoder.decode(chunk as Buffer, { stream: true }) await this.output(data) } - await this.output(decoder.end()) + await this.output(decoder.decode()) const result = await outcome if ('error' in result) throw result.error this.info = { ...this.info, state: 'exited', exitCode: result.value.exitCode } diff --git a/packages/api/terminal-controller/tests/terminal.spec.ts b/packages/api/terminal-controller/tests/terminal.spec.ts index 86c229e158..43c0446458 100644 --- a/packages/api/terminal-controller/tests/terminal.spec.ts +++ b/packages/api/terminal-controller/tests/terminal.spec.ts @@ -59,6 +59,7 @@ describe('BrowserTerminal', () => { const first = await attach(terminal) const encoded = Buffer.from('终端') output.write(encoded.subarray(0, 2)) + await expect.poll(() => output.readableLength).toBe(0) output.write(encoded.subarray(2)) expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' }) const second = await attach(terminal, 'second') @@ -194,6 +195,13 @@ describe('BrowserTerminal', () => { expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) }) + it('preserves a leading UTF-8 BOM in the terminal output stream', async () => { + const { terminal, output } = fixture() + const first = await attach(terminal) + output.write(Buffer.from('\uFEFF终端')) + expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '\uFEFF终端' }) + }) + it('shares concurrent close attempts and permits retry after termination fails', async () => { const { terminal, handle } = fixture() await attach(terminal) diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css index 811de9cacc..817d30bef0 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css @@ -1,9 +1,9 @@ .root { display: flex; flex: 1; flex-direction: column; box-sizing: border-box; height: 100%; min-height: 0; padding-top: 8px; color: var(--dsw-alias-label-primary); font-size: 13px; } .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 1px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; } +.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; } .screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; background: #17191d; } .screen :global(.xterm) { height: 100%; } .status { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); } -.root button { padding: 6px 10px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } +.root button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } .root button:hover { background: var(--dsw-alias-bg-l2); } .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css index c031726424..aabc259179 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css @@ -1,3 +1,3 @@ .stack { position: fixed; right: 20px; bottom: 20px; display: grid; gap: 8px; max-width: min(440px, calc(100vw - 40px)); pointer-events: auto; } -.notice { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l1); box-shadow: 0 4px 20px #0002; font-size: 13px; overflow-wrap: anywhere; } -.notice button { flex-shrink: 0; padding: 4px 8px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 4px; color: inherit; background: transparent; cursor: pointer; } +.notice { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l1); box-shadow: 0 4px 20px #0002; font-size: 13px; overflow-wrap: anywhere; } +.notice button { flex-shrink: 0; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 4px; color: inherit; background: transparent; cursor: pointer; } diff --git a/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl index 86ad93cc03..fc145a45b4 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl @@ -19,7 +19,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract terminalEnvironment(signal?: AbortSignal): Promise\",\n \"description\": \"Inspect shell-selection facts in the provider's execution environment.\",\n \"parameters\": [\n {\n \"name\": \"signal\",\n \"description\": \"cancellation of remote environment inspection.\"\n }\n ],\n \"returns\": \"platform and preferred shell; executable lookup and allocation remain separate operations.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalEnvironment\",\n \"declaration\": \"export interface SubprocessTerminalEnvironment {\\n platform: 'posix' | 'windows';\\n defaultShell?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n resize(cols: number, rows: number): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n terminalType: string;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146037,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788778146037,"index":0,"dt":[],"texts":["CORDIS_INSPECT_JSDOC_OK"]},{"type":"chunk","time":1788778146037,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From 3a32c617a3436f0e9451a0dd08dfe75b62255294 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 17:38:02 +0800 Subject: [PATCH 11/38] feat(trajectory): remember JSON string wrapping preference --- .../ui-primitives/src/JsonTree.module.css | 10 ++++ .../client/ui-primitives/src/JsonTree.tsx | 53 ++++++++++++++++++- .../src/client/TrajectoryTable.tsx | 50 ++++++++++++----- .../src/client/TrajectoryView.tsx | 9 +++- .../client/ui-trajectory/src/client/index.ts | 6 +++ .../ui-trajectory/src/client/locales.ts | 2 + .../src/client/string-wrapping-store.ts | 13 +++++ 7 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 packages/client/ui-trajectory/src/client/string-wrapping-store.ts diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index 536e7bc052..9abbc23bdc 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -145,6 +145,12 @@ height: 16px; } +.stringRaw[data-wrap='true'] { + overflow-wrap: anywhere; + white-space: pre-wrap; + word-break: break-all; +} + .stringActions > .copySlot { opacity: 1; pointer-events: auto; @@ -275,6 +281,10 @@ color: var(--dsw-alias-label-primary); } +.actionButton[aria-pressed='true'] { + color: var(--dsw-alias-state-business-primary); +} + .actionButton:focus-visible { outline: 1px solid var(--dsw-alias-state-business-primary); outline-offset: -1px; diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index b5f0d7a5e8..17e846d983 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -219,6 +219,7 @@ function NodeField({ interface JsonTreeNodeProps { collapsedStringLines: number + stringWrapping: JsonTreeProps['stringWrapping'] field?: string initialExpanded: boolean labels: JsonTreeLabels @@ -233,6 +234,7 @@ interface JsonTreeNodeProps { function JsonString({ collapsedStringLines, + stringWrapping, field, labels, lastElement, @@ -240,6 +242,7 @@ function JsonString({ value, }: { collapsedStringLines: number + stringWrapping: JsonTreeProps['stringWrapping'] field: string | undefined labels: JsonTreeLabels lastElement: boolean @@ -250,6 +253,7 @@ function JsonString({ const contentRef = useRef(null) const rawRef = useRef(null) const [expanded, setExpanded] = useState(false) + const [wrapped, setWrapped] = useState(false) const [truncated, setTruncated] = useState(false) useLayoutEffect(() => { @@ -309,12 +313,41 @@ function JsonString({ ref={rawRef} id={contentsId} className={css.stringRaw} + data-wrap={wrapped} tabIndex={0} aria-labelledby={field === undefined ? undefined : fieldId} > {value}
+ {stringWrapping !== undefined && ( + + )} @@ -372,6 +408,7 @@ function JsonString({ function JsonTreeNode({ collapsedStringLines, + stringWrapping, field, initialExpanded, labels, @@ -427,6 +464,7 @@ function JsonTreeNode({ return row( boolean + /** Remember a user toggle for future expansions. @param wrapped - Whether to wrap long lines. */ + setDefault: (wrapped: boolean) => void + } | undefined /** Whether JSON rows expose copy actions. */ copyable?: boolean /** Whether the top-level object or array is always expanded. */ @@ -551,6 +599,7 @@ export function JsonTree({ label, className, collapsedStringLines = 3, + stringWrapping, copyable = true, expandTopLevel = true, labels, @@ -721,6 +770,7 @@ export function JsonTree({ {t('source.notRecorded')}

const data = typeof source === 'object' && source !== null @@ -896,6 +905,7 @@ function MessageSource({ record, t }: { record: TableRecord; t: TrajectoryTransl return ( {t('record.toolsMissing')}

return (
@@ -1302,6 +1317,7 @@ function ToolCatalog({ )} {t('record.parameters')} {selectedRequestOptions !== undefined && ( { activateTab('options') }}> - + )} { activateTab('usage') }}> @@ -2981,7 +3005,7 @@ export function TrajectoryTable({ )} {selectedRequestInfo !== undefined && activeTab === 'options' && ( - + )} {selectedRequestInfo !== undefined && activeTab === 'usage' && ( + )} {!promptSelected && selected?.cell.kind === 'compacted' @@ -3179,16 +3203,16 @@ export function TrajectoryTable({ <> {selected.cell.inputDetail && ( { activateTab('input') }}> - + )} {selected.cell.outputDetail && ( { activateTab('output') }}> - + )} { activateTab('schema') }}> - + )} @@ -3233,16 +3257,16 @@ export function TrajectoryTable({ /> )} {!promptSelected && selected !== undefined && activeTab === 'source' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'input' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'schema' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'timing' && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 4f8bc63d9c..22a2492d0b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -7,6 +7,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { JsonTreeProps } from '@deepseek-ai/dsh-client-ui-primitives' import { TrajectoryTable, type TrajectoryRequestNumber, @@ -74,6 +75,8 @@ function partialStructureSignature(partial: TrajectorySnapshot['partial']): stri /** Session-bound controls not already supplied by the conversation view slot. */ export interface TrajectoryViewInjected { + /** Shared wrapping preference read by expansion handlers. */ + jsonStringWrapping?: Omit, 'label'> hooks: { duration: SnapshotStore } @@ -128,7 +131,7 @@ function addUsage( export function TrajectoryView({ useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration, - viewRequest, completeViewRequest, renderSlot, t, + viewRequest, completeViewRequest, renderSlot, t, jsonStringWrapping, }: ConvViewProps & PropsRenderSlots<'conversation.trajectory.images'> & InjectFace @@ -541,6 +544,10 @@ export function TrajectoryView({
stringWrapping.getSnapshot(), + setDefault: (value) => { stringWrapping.set(value) }, + }, loadOlder: async () => { const before = trajectory.getSnapshot() await session.loadOlder() diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 6bdc621413..c3b69be06d 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -114,6 +114,7 @@ export const zh = { 'record.payloadJson': '参数 JSON', 'record.outputJson': '结果 JSON', 'record.thinking': '思考', + 'record.wrapLines': '自动换行', 'record.systemPromptMissing': '本次请求没有系统提示词', 'record.toolsMissing': '本次请求没有工具', 'record.systemPrompt': '系统提示词', @@ -307,6 +308,7 @@ export const en: Record = { 'record.payloadJson': 'Payload JSON', 'record.outputJson': 'Result JSON', 'record.thinking': 'Thinking', + 'record.wrapLines': 'Wrap lines', 'record.systemPromptMissing': 'No system prompt in this request', 'record.toolsMissing': 'No tools in this request', 'record.systemPrompt': 'System Prompt', diff --git a/packages/client/ui-trajectory/src/client/string-wrapping-store.ts b/packages/client/ui-trajectory/src/client/string-wrapping-store.ts new file mode 100644 index 0000000000..bcd86dfccc --- /dev/null +++ b/packages/client/ui-trajectory/src/client/string-wrapping-store.ts @@ -0,0 +1,13 @@ +import { + createSnapshotStore, type SnapshotStore, +} from '@deepseek-ai/dsh-client-store' + +/** + * Create the browser-wide default for expanded JSON strings. + * @returns A persisted preference sampled only when a string is expanded. + */ +export function createTrajectoryStringWrappingStore(): SnapshotStore { + return createSnapshotStore(false, { + persist: { name: 'dsh.trajectory.jsonStringWrapping' }, + }) +} From 632b8e19b4ba1297bc8c5931ab209b8d010d8ed2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:15:09 +0800 Subject: [PATCH 12/38] feat(trajectory): add a PTC code inspector --- ...9-ptc-trajectory-code-inspection.i18n.yaml | 6 + ...26-09-09-ptc-trajectory-code-inspection.md | 29 ++ ...09-09-ptc-trajectory-code-inspection.zh.md | 29 ++ apps/web/tests/ptc-round.e2e.ts | 45 ++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/markdown/CodeBlock.tsx | 10 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 + packages/client/ui-trajectory/README.zh.md | 2 + .../src/client/TrajectoryTable.module.css | 139 ++++++++++ .../src/client/TrajectoryTable.tsx | 258 +++++++++++++++--- .../ui-trajectory/src/client/code-program.ts | 57 ++++ .../ui-trajectory/src/client/locales.ts | 12 + .../tests/code-inspector.client.spec.tsx | 116 ++++++++ .../tests/code-program.client.spec.ts | 60 ++++ snapshots/web/ptc-round/code.expected.md | 7 + .../web/ptc-round/trajectory.expected.md | 22 ++ 19 files changed, 759 insertions(+), 47 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md create mode 100644 .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md create mode 100644 packages/client/ui-trajectory/src/client/code-program.ts create mode 100644 packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx create mode 100644 packages/client/ui-trajectory/tests/code-program.client.spec.ts create mode 100644 snapshots/web/ptc-round/code.expected.md create mode 100644 snapshots/web/ptc-round/trajectory.expected.md diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml new file mode 100644 index 0000000000..d273d9ed77 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md +2026-09-09-ptc-trajectory-code-inspection.md: 55cbbae97371b69b7574ebb0674c2178b586b83a +2026-09-09-ptc-trajectory-code-inspection.zh.md: 94b6993a4e4e2d1f3e93256f79a68121f910169d diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md new file mode 100644 index 0000000000..55cbbae973 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md @@ -0,0 +1,29 @@ +# Agent Note: Inspect recorded PTC source in Trajectory + +Status: implemented + +English | [中文](2026-09-09-ptc-trajectory-code-inspection.zh.md) + +## Problem + +PTC programs arrive as JSON string arguments. Escaping makes long programs difficult to read in a generic argument tree, while historical calls may use a different runtime language from the current deployment. Recorded result text can contain both printed output and a returned value without retaining their separation. + +## Decision + +[Trajectory](../../../../packages/client/ui-trajectory/README.md) derives a code inspector from recorded `run_code` arguments. It preserves source bytes for copying and exposes the original JSON through a separate toggle. Syntax highlighting requires an unambiguous TypeScript or Python hint in that call's recorded parameter description; unknown or conflicting hints leave plain text. Unsupported arguments retain the generic inspector. + +The result view preserves recorded text and uses a tree only for complete JSON objects or arrays. Errors retain captured output. Code views sample the shared wrapping preference when opened and keep their own choice while mounted. + +The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlement; the [client presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md) still owns deriving UI from recorded facts. This inspector adds no Session events or host presentation fields. + +## Alternatives considered + +**Keep source inside the argument tree.** JSON escaping obscures program structure and makes copying executable source cumbersome. + +**Use the active runtime language or infer it from source.** Either can mislabel a historical program. Recorded schema hints constrain highlighting without changing the recording. + +**Split printed output from return values.** Historical rendered text does not establish that distinction; a parser could invent a separation the producer never recorded. + +## Consequences + +Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Existing component tests cover fallback, exact copying, output states, and independent wrapping; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip. diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md new file mode 100644 index 0000000000..94b6993a4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 在 Trajectory 中检查已记录的 PTC 源码 + +Status: implemented + +[English](2026-09-09-ptc-trajectory-code-inspection.md) | 中文 + +## 问题 + +PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树中难以阅读,而历史调用使用的运行时语言可能不同于当前部署。记录的结果文本可以同时包含打印输出与返回值,却未保留二者的分界。 + +## 决策 + +[Trajectory](../../../../packages/client/ui-trajectory/README.zh.md) 从已记录的 `run_code` 参数派生代码检查器。复制时保留源码字节,并通过独立切换按钮展示原始 JSON。语法高亮要求该调用记录的参数说明包含明确且无冲突的 TypeScript 或 Python 提示;未知或冲突的提示使用纯文本。不支持的参数保留通用检查器。 + +结果视图保留记录的文本,只对完整 JSON 对象或数组使用树形展示。错误保留已捕获的输出。代码视图在打开时读取共享换行偏好,并在挂载期间保留自身的选择。 + +[PTC 运行时决策](2026-06-15-ptc.zh.md) 仍负责执行与结算;[客户端展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md) 仍负责从已记录事实派生 UI。此检查器不增加 Session 事件或宿主展示字段。 + +## 考虑过的替代方案 + +**把源码保留在参数树中。** JSON 转义遮蔽程序结构,也使复制可执行源码变得繁琐。 + +**使用当前运行时语言,或从源码推断语言。** 两种方式都可能错误标注历史程序。已记录 Schema 的提示约束高亮,无需修改记录。 + +**拆分打印输出与返回值。** 历史渲染文本无法确定二者的分界;解析器可能凭空添加生产方从未记录的分隔。 + +## 后果 + +读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。现有组件测试覆盖回退、原样复制、输出状态与独立换行;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。 diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index 045c475bb6..54bcc461c9 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -8,13 +8,15 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts, + acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url)) +const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url)) +const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url)) const MODE = webSnapshotMode() // Elicits the successful and failed sub-rows this scenario asserts. @@ -151,6 +153,47 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) + it.skipIf(MODE === 'record')('inspects recorded PTC source, wrapping, and original JSON in the trajectory', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-inspector')) + const call = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'run_code') + if (call?.type !== 'tool/call') throw new Error('recorded PTC call missing') + const args = JSON.parse(call.data.arguments) as { code: string; description: string } + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + const row = page.locator('tr[data-kind="tool"]').filter({ hasText: 'run_code' }).first() + await row.click() + await page.getByRole('tab', { name: 'Code', exact: true }).waitFor() + expect(await page.getByRole('tabpanel').textContent()).toContain(args.description) + expect(await page.getByRole('tabpanel').locator('dl').first().locator('dt').allTextContents()) + .toEqual(['Hierarchy', 'Status']) + const overview = await Promise.all([1, 2].map(index => captureStableAria( + page, `[role="tabpanel"] [class*="overviewSections"] > section:nth-child(${index})`, scaffold.workspaceCwd, + ))) + await compareOrRefreshGolden(TRAJECTORY_EXPECTED, overview.join('\n'), MODE) + + await page.getByRole('button', { name: 'Code', exact: true }).click() + const source = page.locator('[data-line-numbers] pre') + await source.waitFor() + expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code) + const wrap = page.getByRole('button', { name: 'Wrap lines', exact: true }) + expect(await wrap.getAttribute('aria-pressed')).toBe('false') + const content = page.locator('[data-wrap]').filter({ has: source }) + expect(await content.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true) + await wrap.click() + expect(await wrap.getAttribute('aria-pressed')).toBe('true') + await expect.poll(() => content.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1) + const code = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(CODE_EXPECTED, code, MODE) + + await page.getByRole('button', { name: 'Original JSON' }).click() + const json = page.getByRole('tree', { name: 'parameters JSON' }) + await json.waitFor() + expect(await json.textContent()).toContain(args.description) + await page.getByRole('button', { name: 'Original JSON' }).click() + expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code) + await page.getByRole('tab', { name: 'Summary', exact: true }).click() + expect(await wrap.getAttribute('aria-pressed')).toBe('true') + }) + it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 138d94440f..b10bb57879 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73 -README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6 +README.md: 6b92450428e1b109db127e99de282e215a98eb8a +README.zh.md: dc8e07af8021c65cca1694cb8a8e8e8579269619 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 8fd59c5ecc..6b92450428 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | -| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. | +| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. | | `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2478330825..dc8e07af80 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -52,7 +52,7 @@ kind: "package-library" | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | -| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 | +| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 | | `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 | diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index fff6a00a45..9e2dddc600 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -27,6 +27,8 @@ export interface CodeBlockProps { className?: string | undefined /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */ lineNumbers?: boolean | undefined + /** Show the language and copy header; false when the caller supplies a toolbar. Defaults to true. */ + showHeader?: boolean | undefined /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */ copyLabel: string /** Copy-button label during the post-copy confirmation window. */ @@ -59,7 +61,9 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode { ) } -export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) { +export function CodeBlock({ + code, lang, streaming, className, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, +}: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code const sourceLines = lineNumbers ? trimmed.split('\n') : undefined const rootRef = useRef(null) @@ -177,7 +181,7 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals style={sourceLines === undefined ? undefined : { '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`, } as CSSProperties}> -
+ {showHeader &&
{lang ?? ''}
@@ -186,7 +190,7 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
-
+
} {body}
) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 741beb718f..02a420834f 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 099ce8c9330040e106ab4ab1bfbf155d32a38e79 -README.zh.md: 7b00cd8ccf8dfdc36f6df55e3b0e7c1ae8fe5847 +README.md: 80d8898e0fabd4671581a84198250fe408d51009 +README.zh.md: 5c85faaadb56be6dc3cc85753494c7fd88333c92 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 099ce8c933..80d8898e0f 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -29,6 +29,8 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit ### Inspecting records +For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints. + Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. ### The timing overview diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 7b00cd8ccf..5c85faaadb 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -29,6 +29,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 ### 检查记录 +对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 + 选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留 snapshot。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 ### 时间概览 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 414e123d9e..c743e0e503 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1261,6 +1261,145 @@ padding-bottom: 0; } +.programDescription { + flex: none; + margin: 0; + padding: 8px 14px 2px; + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-13); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.programIcon { + margin-right: 4px; + vertical-align: -2px; +} + +.programSummary { + margin-left: 5px; + font-family: var(--dsw-font-family); +} + +.detailBodyProgram, +.programPanel { + display: flex; + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.programPanel { + flex: 1; + background: var(--dsw-alias-bg-layer-1); +} + +.programActions { + display: inline-flex; + flex: none; + height: 16px; + align-items: center; + gap: 4px; + margin-left: auto; + padding-right: 14px; +} + +.programLanguage { + padding-right: 4px; + color: var(--dsw-alias-label-tertiary); + font: 11px/16px var(--ds-font-family-code); +} + +.programAction { + display: inline-flex; + box-sizing: border-box; + width: 20px; + height: 16px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + color: var(--dsw-alias-label-secondary); + background: transparent; + cursor: pointer; +} + +.programAction:hover { + color: var(--dsw-alias-label-primary); +} + +.programAction[aria-pressed='true'] { + color: var(--dsw-alias-state-business-primary); +} + +.programAction[data-state='failed'] { + color: var(--dsw-alias-state-error-primary); +} + +.programAction:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: -1px; +} + +.programContent { + min-width: 0; + min-height: 0; +} + +.programPanel > .programContent { + flex: 1; + overflow: auto; + overscroll-behavior: contain; +} + +.programSource { + margin: 0 !important; + border-radius: 0; + background: var(--dsw-alias-bg-layer-1); + --dsl-code-block-line-white-space: pre; +} + +.programSource :where(pre) { + padding: 2px 14px 8px; + overflow: visible; + border-radius: 0; + background: var(--dsw-alias-bg-layer-1) !important; + font: 12px/16px var(--ds-font-family-code); + overflow-wrap: normal; + word-break: normal; + tab-size: 2; +} + +.programSource pre code > :global(.line) { + box-sizing: border-box; + width: max-content; + min-width: 100%; +} + +.programContent[data-wrap='true'] .programSource { + --dsl-code-block-line-white-space: pre-wrap; +} + +.programContent[data-wrap='true'] .programSource pre code > :global(.line) { + width: auto; + overflow-wrap: anywhere; + word-break: break-all; +} + +.programOutput { + margin: 0; + padding: 2px 14px 8px; + color: var(--dsw-alias-label-primary); + font: 12px/16px var(--ds-font-family-code); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.programError::first-line { + color: var(--dsw-alias-state-error-primary); + font-weight: 600; +} + .overviewPreview .jsonPreview > :first-child, .overviewPreview .schemaTree > :first-child { padding-top: 2px; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 2736c6fd04..eb743e5881 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1,16 +1,21 @@ /** Turn-aware trajectory event ledger with a local record inspector. */ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { + CodeBlock, + IconCheckOutline16, IconChevronRightOutline14, + IconCodeOutline16, + IconCopyOutline16, IconSettingsOutline16, IconSparkle16, IconUserOutline16, JsonTree, MarkdownText, Tooltip, + writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { JsonTreeLabels, JsonTreeProps, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' @@ -30,6 +35,7 @@ import type { TrajectoryTurnModel } from './layout.ts' import { trajectoryPreviewText } from './trajectory-preview.ts' import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts' import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts' +import { codeProgram, type CodeProgram } from './code-program.ts' import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 @@ -991,8 +997,11 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { } return [ { id: 'overview', labelKey: 'tab.summary' }, - ...(record.cell.inputDetail ? [{ id: 'input', labelKey: 'tab.payload' } as const] : []), - ...(record.cell.outputDetail ? [{ id: 'output', labelKey: 'tab.result' } as const] : []), + ...(record.cell.inputDetail ? [{ + id: 'input', labelKey: codeProgram(record.cell) === undefined ? 'tab.payload' : 'code.source', + } as const] : []), + ...(record.cell.outputDetail || codeProgram(record.cell) !== undefined + ? [{ id: 'output', labelKey: 'tab.result' } as const] : []), { id: 'schema', labelKey: 'tab.schema' }, { id: 'timing', labelKey: 'tab.timing' }, ] @@ -1000,6 +1009,8 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { function recordDisplayText(cell: TrajectoryCellProps, t: TrajectoryTranslate): string { if (isToolCallOnly(cell, t)) return '' + const program = codeProgram(cell) + if (program !== undefined) return `${cell.text.split(' · ', 1)[0]} · ${program.description.replace(/\s+/g, ' ')}` if (cell.previewMarkdown !== undefined) { const preview = trajectoryPreviewText(cell.previewMarkdown) if (cell.text === '') return preview @@ -1099,10 +1110,11 @@ function RecordListText({ return ( <> + {toolCallText.name === 'run_code' && } {toolCallText.name || '—'} {toolCallText.args !== undefined && ( - + {toolCallText.args} )} @@ -1801,13 +1813,159 @@ function parseJsonContainer(value: string): object | undefined { } } +function InspectorCopyButton({ text, label, t }: { + text: string + label: string + t: TrajectoryTranslate +}) { + const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle') + useEffect(() => { + if (state === 'idle') return + const timer = setTimeout(() => { setState('idle') }, 1_500) + return () => { clearTimeout(timer) } + }, [state]) + const title = state === 'idle' ? label : t(state === 'copied' ? 'copied' : 'copy.failed') + return ( + + ) +} + +function ProgramInput({ program, rawInput, initialWrapped, stringWrapping, onOpen, t }: { + program: CodeProgram + rawInput: string + initialWrapped: boolean + stringWrapping: JsonTreeProps['stringWrapping'] + onOpen?: () => void + t: TrajectoryTranslate +}) { + const contentsId = useId() + const [wrapped, setWrapped] = useState(initialWrapped) + const [showJson, setShowJson] = useState(false) + const actions = ( + + {!showJson && program.language !== undefined && ( + {program.language} + )} + {!showJson && ( + + )} + {onOpen === undefined && ( + + )} + + + ) + const body = ( +
+ {showJson + ? + : } +
+ ) + return onOpen === undefined + ? ( +
+
+ {t('code.source')} + {actions} +
+ {body} +
+ ) + : {body} +} + +function ProgramOutput({ record, stringWrapping, onOpen, t }: { + record: TableRecord + stringWrapping: JsonTreeProps['stringWrapping'] + onOpen?: () => void + t: TrajectoryTranslate +}) { + const output = record.cell.outputDetail + const json = output === undefined || record.cell.isError === true ? undefined : parseJsonContainer(output) + const actions = output === undefined ? undefined : ( + + + + ) + const body = ( +
+ {output === undefined || output === '' + ?

{t(stateOf(record) === 'running' ? 'code.running' : 'record.noOutput')}

+ : json !== undefined + ? + :
{output}
} +
+ ) + return onOpen === undefined + ? ( +
+
+ {t('code.output')} + {actions} +
+ {body} +
+ ) + : {body} +} + function OverviewSection({ label, onOpen, + actions, children, }: { label: string onOpen: () => void + actions?: ReactNode children: ReactNode }) { const previewRef = useRef(null) @@ -1838,6 +1996,7 @@ function OverviewSection({ {label} + {actions}
(null) const [selectedRequest, setSelectedRequest] = useState(null) const [activeTab, setActiveTab] = useState('overview') + const [codeWrappingOnOpen, setCodeWrappingOnOpen] = useState(false) const [thinkingDisclosure, setThinkingDisclosure] = useState<{ recordId: string; expanded: boolean }>() const [detailsWidth, setDetailsWidth] = useState(null) const [toolRequestOffset, setToolRequestOffset] = useState(null) @@ -1919,6 +2079,7 @@ export function TrajectoryTable({ const selected = selectedTemplate === undefined ? undefined : currentRecord(selectedTemplate) + const selectedProgram = selected === undefined ? undefined : codeProgram(selected.cell) const thinkingExpanded = thinkingDisclosure?.recordId === selectedRecordId ? thinkingDisclosure.expanded : selected?.cell.kind === 'message' @@ -2130,6 +2291,7 @@ export function TrajectoryTable({ } const activateTab = (tab: DetailTab) => { + setCodeWrappingOnOpen(stringWrapping?.getDefault() ?? false) tabHistory.current.delete(tab) tabHistory.current.add(tab) setActiveTab(tab) @@ -2146,6 +2308,7 @@ export function TrajectoryTable({ } const selectRecord = useCallback((index: number) => { + setCodeWrappingOnOpen(stringWrapping?.getDefault() ?? false) const record = allRecords.find(candidate => candidate.cell.index === index) onRecordSelect?.(index) setSelectedRequest(null) @@ -2155,7 +2318,7 @@ export function TrajectoryTable({ const available = new Set(tabs.map(tab => tab.id)) const recent = [...tabHistory.current].reverse().find(tab => available.has(tab)) setActiveTab(recent ?? tabs[0]?.id ?? 'overview') - }, [allRecords, onRecordSelect]) + }, [allRecords, onRecordSelect, stringWrapping]) useEffect(() => { if ( recordSelection === null @@ -2845,7 +3008,7 @@ export function TrajectoryTable({ {selected.cell.kind === 'compacted' ? sectionLabel(selected.turn, t) - : `${sectionLabel(selected.turn, t)} · ${selected.group}`} + : selected.group} )} @@ -2879,7 +3042,9 @@ export function TrajectoryTable({ id="trajectory-detail-panel" className={activeTab === 'overview' ? `${css.detailBody} ${css.detailBodySummary}` - : css.detailBody} + : selectedProgram !== undefined && (activeTab === 'input' || activeTab === 'output') + ? `${css.detailBody} ${css.detailBodyProgram}` + : css.detailBody} role="tabpanel" aria-labelledby={`trajectory-detail-${activeTab}`} > @@ -3091,6 +3256,9 @@ export function TrajectoryTable({ && selectedState !== undefined && activeTab === 'overview' && ( <> + {selectedProgram !== undefined && selectedProgram.description !== '' && ( +

{selectedProgram.description}

+ )}
- {isMarkdownRecord(selected) + {selectedProgram !== undefined ? ( <> - { activateTab('rendered') }}> - - + { activateTab('input') }} t={t} /> + { activateTab('output') }} t={t} /> ) - : ( - <> - {selected.cell.inputDetail && ( - { activateTab('input') }}> - + : isMarkdownRecord(selected) + ? ( + <> + { activateTab('rendered') }}> + - )} - {selected.cell.outputDetail && ( - { activateTab('output') }}> - + + ) + : ( + <> + {selected.cell.inputDetail && ( + { activateTab('input') }}> + + + )} + {selected.cell.outputDetail && ( + { activateTab('output') }}> + + + )} + { activateTab('schema') }}> + - )} - { activateTab('schema') }}> - - - - )} + + )} {selectedAssistantRequestTarget !== undefined && ( )} {!promptSelected && selected !== undefined && activeTab === 'input' && ( - + selectedProgram === undefined + ? + : )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( - + selectedProgram === undefined + ? + : )} {!promptSelected && selected !== undefined && activeTab === 'schema' && ( diff --git a/packages/client/ui-trajectory/src/client/code-program.ts b/packages/client/ui-trajectory/src/client/code-program.ts new file mode 100644 index 0000000000..98762cdcbd --- /dev/null +++ b/packages/client/ui-trajectory/src/client/code-program.ts @@ -0,0 +1,57 @@ +/** Read replayable PTC source and language hints from recorded tool arguments and schemas. */ +import type { TrajectoryCellProps } from './trajectory-record.ts' + +/** Validated source and original arguments for one recorded run_code call. */ +export interface CodeProgram { + source: string + description: string + arguments: Record + language: 'typescript' | 'python' | undefined +} + +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +function parseRecord(value: string | undefined): Record | undefined { + if (value === undefined) return undefined + try { + return record(JSON.parse(value)) + } catch { + return undefined + } +} + +function recordedLanguage(schemaRaw: string | undefined): CodeProgram['language'] { + const schema = parseRecord(schemaRaw) + const properties = record(record(schema?.parameters)?.properties) + const description = record(properties?.code)?.description + if (typeof description !== 'string') return undefined + const typescript = /\bTypeScript\b/i.test(description) + const python = /\bPython\b/i.test(description) + if (typescript === python) return undefined + return typescript ? 'typescript' : 'python' +} + +/** + * Resolve a PTC program without guessing its language from source or current runtime settings. + * @param cell - Recorded tool arguments and the schema visible at call time. + * @returns The program, or undefined for another tool or unsupported arguments. + */ +export function codeProgram(cell: TrajectoryCellProps): CodeProgram | undefined { + if (cell.kind !== 'tool' && cell.kind !== 'subtool') return undefined + if (cell.text.split(' · ', 1)[0] !== 'run_code') return undefined + const args = parseRecord(cell.inputDetail) + if (typeof args?.code !== 'string') return undefined + if (args.description !== undefined && typeof args.description !== 'string') return undefined + return { + source: args.code, + description: typeof args.description === 'string' && args.description.trim() !== '' + ? args.description + : args.code.split(/\r?\n/).find(line => line.trim() !== '')?.trim() ?? '', + arguments: args, + language: recordedLanguage(cell.schemaDetail), + } +} diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index c3b69be06d..c2fbe6b25c 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -115,6 +115,12 @@ export const zh = { 'record.outputJson': '结果 JSON', 'record.thinking': '思考', 'record.wrapLines': '自动换行', + 'code.source': '代码', + 'code.output': '输出', + 'code.copySource': '复制代码', + 'code.copyOutput': '复制输出', + 'code.originalJson': '原始 JSON', + 'code.running': '执行中…', 'record.systemPromptMissing': '本次请求没有系统提示词', 'record.toolsMissing': '本次请求没有工具', 'record.systemPrompt': '系统提示词', @@ -309,6 +315,12 @@ export const en: Record = { 'record.outputJson': 'Result JSON', 'record.thinking': 'Thinking', 'record.wrapLines': 'Wrap lines', + 'code.source': 'Code', + 'code.output': 'Output', + 'code.copySource': 'Copy code', + 'code.copyOutput': 'Copy output', + 'code.originalJson': 'Original JSON', + 'code.running': 'Running…', 'record.systemPromptMissing': 'No system prompt in this request', 'record.toolsMissing': 'No tools in this request', 'record.systemPrompt': 'System Prompt', diff --git a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx new file mode 100644 index 0000000000..748463b6ea --- /dev/null +++ b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +/** PTC source, output, and independent wrapping through the trajectory inspector. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' +import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' +import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts' +import { t } from './locale.client.ts' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +const SOURCE = 'const value = await tools.bash({ command: "pwd" })\nreturn value\n' +const ARGS = JSON.stringify({ code: SOURCE, description: 'Read the working directory' }) +const CELL: TrajectoryCellProps = { + index: 1, kind: 'tool', text: `run_code · ${ARGS}`, callId: 'code-1', + inputDetail: ARGS, outputDetail: 'printed line\n42', result: 'printed line', timeSeconds: 0.5, +} + +function Harness({ cell = CELL, getDefault = () => false, setDefault = () => {} }: { + cell?: TrajectoryCellProps + getDefault?: () => boolean + setDefault?: (value: boolean) => void +}) { + return null} t={t} + stringWrapping={{ label: 'Wrap lines', getDefault, setDefault }} + collapsedTurns={new Set()} onToggleTurn={() => {}} + collapsedAssistants={new Set()} onToggleAssistant={() => {}} + /> +} + +describe('PTC trajectory inspector', () => { + it('shows the description, numbered source, original arguments, and verbatim output', () => { + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read the working directory/ })) + expect(ui.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Summary', 'Code', 'Result', 'Schema', 'Timing']) + expect(ui.getByRole('tabpanel').textContent).toContain('Read the working directory') + expect(view.container.querySelector('[data-line-numbers] pre')?.textContent).toBe(SOURCE.trimEnd()) + expect(ui.getByRole('tabpanel').textContent).toContain('printed line\n42') + fireEvent.click(ui.getByRole('button', { name: 'Code' })) + expect(ui.getByRole('tab', { name: 'Code' }).getAttribute('aria-selected')).toBe('true') + fireEvent.click(ui.getByRole('button', { name: 'Original JSON' })) + expect(ui.getByRole('tree', { name: 'parameters JSON' }).textContent).toContain('description:') + fireEvent.click(ui.getByRole('button', { name: 'Original JSON' })) + expect(view.container.querySelector('[data-line-numbers] pre')?.textContent).toBe(SOURCE.trimEnd()) + }) + + it('copies exact source, raw JSON, and output with feedback', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read/ })) + fireEvent.click(ui.getByRole('button', { name: 'Copy code' })) + await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(SOURCE) }) + await waitFor(() => { expect(ui.getByRole('button', { name: 'Copied' })).toBeTruthy() }) + fireEvent.click(ui.getByRole('button', { name: 'Copy output' })) + await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('printed line\n42') }) + fireEvent.click(ui.getByRole('tab', { name: 'Code' })) + fireEvent.click(ui.getByRole('button', { name: 'Original JSON' })) + fireEvent.click(ui.getByRole('button', { name: 'Copy JSON' })) + await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(ARGS) }) + }) + + it('samples the last wrapping choice on open without changing another open inspector', () => { + let preference = false + const props = { getDefault: () => preference, setDefault: (value: boolean) => { preference = value } } + const first = render() + const second = render() + const a = within(first.container) + const b = within(second.container) + fireEvent.click(a.getByRole('row', { name: /run_code Read/ })) + fireEvent.click(b.getByRole('row', { name: /run_code Read/ })) + fireEvent.click(a.getByRole('button', { name: 'Wrap lines' })) + expect(preference).toBe(true) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + fireEvent.click(b.getByRole('tab', { name: 'Code' })) + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + fireEvent.click(b.getByRole('button', { name: 'Wrap lines' })) + expect(preference).toBe(false) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + fireEvent.click(a.getByRole('button', { name: 'Close details' })) + fireEvent.click(a.getByRole('row', { name: /run_code Read/ })) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + }) + + it('shows pending execution and keeps a failed run’s captured output', () => { + const pending = { ...CELL, timeSeconds: null } + delete pending.result + delete pending.outputDetail + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read/ })) + expect(ui.getByRole('tabpanel').textContent).toContain('Running…') + view.rerender() + expect(ui.getByRole('tabpanel').textContent).toContain('Failed') + expect(ui.getByRole('tabpanel').textContent).toContain('Error: execution failed\nCaptured output:\nstep one') + }) + + it('uses a tree for complete JSON output and generic input for malformed arguments', () => { + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /run_code Read/ })) + expect(ui.getByRole('tree', { name: 'Result JSON' }).textContent).toContain('passed:12') + view.rerender() + expect(ui.getByRole('tab', { name: 'Payload' })).toBeTruthy() + expect(ui.queryByRole('tab', { name: 'Code' })).toBeNull() + expect(ui.getByRole('tree', { name: 'Payload JSON' }).textContent).toContain('code:3') + }) +}) diff --git a/packages/client/ui-trajectory/tests/code-program.client.spec.ts b/packages/client/ui-trajectory/tests/code-program.client.spec.ts new file mode 100644 index 0000000000..c0c96ca044 --- /dev/null +++ b/packages/client/ui-trajectory/tests/code-program.client.spec.ts @@ -0,0 +1,60 @@ +/** Recorded PTC argument validation and historical language selection. */ +import { describe, expect, it } from 'vitest' +import { codeProgram } from '../src/client/code-program.ts' +import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts' + +const CELL: TrajectoryCellProps = { + index: 1, kind: 'tool', text: 'run_code · {}', timeSeconds: 1, + inputDetail: JSON.stringify({ code: 'return 42\n', description: 'Compute the answer' }), +} + +describe('recorded code programs', () => { + it('keeps the program and arguments verbatim', () => { + expect(codeProgram(CELL)).toEqual({ + source: 'return 42\n', description: 'Compute the answer', + arguments: { code: 'return 42\n', description: 'Compute the answer' }, language: undefined, + }) + }) + + it.each([ + ['The program: the body of an async TypeScript function.', 'typescript'], + ['The program: the body of an async Python function.', 'python'], + ['A TypeScript or Python program.', undefined], + ['An executable program.', undefined], + ])('uses the recorded parameter description %s', (description, language) => { + const schemaDetail = JSON.stringify({ parameters: { properties: { code: { description } } } }) + expect(codeProgram({ ...CELL, schemaDetail })?.language).toBe(language) + }) + + it.each([undefined, '{', '{}', 'null', '[]'])('keeps missing or unusable schemas plain: %s', (schemaDetail) => { + expect(codeProgram({ ...CELL, ...(schemaDetail === undefined ? {} : { schemaDetail }) })?.language).toBeUndefined() + }) + + it('uses the first nonempty source line when a description is unavailable', () => { + const source = '\n const x = 1\nreturn x\n' + for (const description of [undefined, '', ' ']) { + expect(codeProgram({ ...CELL, inputDetail: JSON.stringify({ code: source, description }) })) + .toMatchObject({ source, description: 'const x = 1' }) + } + }) + + it('retains empty source and unknown argument fields for JSON inspection', () => { + expect(codeProgram({ ...CELL, inputDetail: '{"code":"","extra":true}' })) + .toMatchObject({ source: '', description: '', arguments: { code: '', extra: true } }) + }) + + it.each([undefined, '{', '[]', 'null', '42', '{}', '{"code":42}', '{"code":"x","description":[]}' ])( + 'declines incomplete or unsupported arguments: %s', (inputDetail) => { + const cell = { ...CELL } + if (inputDetail === undefined) delete cell.inputDetail + else cell.inputDetail = inputDetail + expect(codeProgram(cell)).toBeUndefined() + }, + ) + + it('does not specialize other tools or non-tool records', () => { + expect(codeProgram({ ...CELL, kind: 'message' })).toBeUndefined() + expect(codeProgram({ ...CELL, text: 'run_code_extra · {}' })).toBeUndefined() + expect(codeProgram({ ...CELL, kind: 'subtool' })?.source).toBe('return 42\n') + }) +}) diff --git a/snapshots/web/ptc-round/code.expected.md b/snapshots/web/ptc-round/code.expected.md new file mode 100644 index 0000000000..5e1d42ae59 --- /dev/null +++ b/snapshots/web/ptc-round/code.expected.md @@ -0,0 +1,7 @@ +- tabpanel "Code": + - text: Code typescript + - button "Wrap lines" [pressed] + - button "Original JSON" + - button "Copy code": + - img + - code: "const bashResult = await tools.bash({ command: \"echo CODE_ROUND_OK\", description: \"Echo CODE_ROUND_OK\" }); let readError = null; try { await tools.read({ file_path: \"missing.txt\" }); } catch (e) { readError = { toolName: e.toolName, message: e.message }; } return { bash: bashResult.stdout.text.trim(), readError };" diff --git a/snapshots/web/ptc-round/trajectory.expected.md b/snapshots/web/ptc-round/trajectory.expected.md new file mode 100644 index 0000000000..ed88d3b743 --- /dev/null +++ b/snapshots/web/ptc-round/trajectory.expected.md @@ -0,0 +1,22 @@ +- heading "Code typescript Wrap lines Copy code" [level=3]: + - button "Code": + - text: Code + - img + - text: typescript + - button "Wrap lines" + - button "Copy code": + - img +- code: "const bashResult = await tools.bash({ command: \"echo CODE_ROUND_OK\", description: \"Echo CODE_ROUND_OK\" }); let readError = null; try { await tools.read({ file_path: \"missing.txt\" }); } catch (e) { readError = { toolName: e.toolName, message: e.message }; } return { bash: bashResult.stdout.text.trim(), readError };" +- heading "Output Copy output" [level=3]: + - button "Output": + - text: Output + - img + - button "Copy output": + - img +- text: "{" +- tree "Result JSON": + - treeitem "bash:\"CODE_ROUND_OK\"," + - 'treeitem "Expand readError:{toolName: \"read\", message: \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"}"': + - button "Expand" + - text: "readError:{toolName: \"read\", message: \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"}" +- text: "}" From 0a525601a76ea0f37faab1ad29e9eb11b2319c6e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 9 Sep 2026 18:17:47 +0800 Subject: [PATCH 13/38] fix(web): harden sidebar terminal lifecycle and recovery --- .../2026-09-09-web-sidebar-terminal.i18n.yaml | 4 +- .../2026-09-09-web-sidebar-terminal.md | 4 +- .../2026-09-09-web-sidebar-terminal.zh.md | 4 +- .../sidebar-terminal/limit.expected.md | 1 + .../tests/fixtures/sidebar-terminal.patch.yml | 1 + apps/web/tests/sidebar-terminal.e2e.ts | 35 ++++- .../api/terminal-controller/README.i18n.yaml | 4 +- packages/api/terminal-controller/README.md | 8 +- packages/api/terminal-controller/README.zh.md | 8 +- .../terminal-controller/src/client/index.ts | 17 ++- .../terminal-controller/src/client/model.ts | 65 ++++++--- packages/api/terminal-controller/src/index.ts | 5 +- .../api/terminal-controller/src/terminal.ts | 17 ++- packages/api/terminal-controller/src/types.ts | 10 ++ .../tests/controller.spec.ts | 4 +- .../tests/model.client.spec.ts | 74 ++++++++-- .../tests/recovery.client.spec.ts | 70 +++++++++- .../tests/terminal.spec.ts | 10 +- .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 2 +- packages/client/ui-sidebar-right/README.zh.md | 2 +- .../src/client/contract/slots.ts | 2 +- .../ui-sidebar-right/src/client/index.ts | 4 +- .../ui-sidebar-right/src/client/service.ts | 41 ++---- .../ui-sidebar-right/src/client/tab-domain.ts | 4 +- .../tests/apply.client.spec.ts | 4 +- .../tests/seat.client.spec.tsx | 14 +- .../tests/service.client.spec.ts | 127 +++++------------- .../tests/tab-domain.client.spec.ts | 2 +- .../ui-sidebar-terminal/README.i18n.yaml | 4 +- packages/client/ui-sidebar-terminal/README.md | 2 +- .../client/ui-sidebar-terminal/README.zh.md | 2 +- .../src/client/TerminalBody.tsx | 2 +- .../ui-sidebar-terminal/src/client/locales.ts | 10 ++ .../tests/apply.client.spec.ts | 4 +- .../tests/terminal-body.client.spec.tsx | 16 ++- packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 2 +- packages/e2b/subprocess-e2b/README.zh.md | 2 +- packages/e2b/subprocess-e2b/src/index.ts | 2 +- .../e2b/subprocess-e2b/tests/terminal.spec.ts | 2 + .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/index.ts | 2 +- .../subprocess-local/tests/local.spec.ts | 6 + 46 files changed, 384 insertions(+), 231 deletions(-) create mode 100644 apps/web/tests/expected/sidebar-terminal/limit.expected.md diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index 3da12183fa..d976a5b494 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: de51d0bb849de0599dfdaf86d2293c17618059cf -2026-09-09-web-sidebar-terminal.zh.md: e6aacebc424efc12457b4a31f0b2660a3ad6225b +2026-09-09-web-sidebar-terminal.md: 4339e6aa0bbb28383a9f2b42336965d44b1a0e07 +2026-09-09-web-sidebar-terminal.zh.md: aec1286b0d6e594813735acfd06dbaf34f261972 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index de51d0bb84..4339e6aa0b 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -12,13 +12,13 @@ Web users need an interactive shell beside a Session to inspect the workspace an `api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal starts the execution environment's default shell immediately. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs only during creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. -Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. +Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry. The caller retains a terminal ID in memory before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments. -The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. +The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index e6aacebc42..aec1286b0d 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -12,13 +12,13 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 `api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端立即启动执行环境的默认 shell。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 只在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 -关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 +关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。 调用者在创建之前把终端 ID 保留在内存中。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。 -最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 +最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。 ## 考虑过的替代方案 diff --git a/apps/web/tests/expected/sidebar-terminal/limit.expected.md b/apps/web/tests/expected/sidebar-terminal/limit.expected.md new file mode 100644 index 0000000000..845c392f43 --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/limit.expected.md @@ -0,0 +1 @@ +- alert: "Terminal error: The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit." diff --git a/apps/web/tests/fixtures/sidebar-terminal.patch.yml b/apps/web/tests/fixtures/sidebar-terminal.patch.yml index 0c055161f1..cc5d4b94f3 100644 --- a/apps/web/tests/fixtures/sidebar-terminal.patch.yml +++ b/apps/web/tests/fixtures/sidebar-terminal.patch.yml @@ -1,5 +1,6 @@ - id: terminal-controller config: + maxTerminals: 2 shell: path: /bin/bash name: bash diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index ed416301b6..ea79df7ec2 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -12,12 +12,13 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const expected = fileURLToPath(new URL('./expected/sidebar-terminal/running.expected.md', import.meta.url)) const shots = fileURLToPath(new URL('../../../.artifacts/screenshots/sidebar-terminal/', import.meta.url)) -async function openTerminal(page: Page): Promise { +async function openTerminal(page: Page, waitForShell = true): Promise { const expand = page.locator('[data-sidebar-right-expand]') if (await expand.isVisible()) await expand.click() - else await page.locator('[data-dockkit-add-tab]').click() - await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() - await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-') + const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]') + if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click() + await entry.click() + if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-') } async function command(page: Page, text: string): Promise { @@ -142,4 +143,30 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!).length).toBe(0) expect(tripwire.pageErrors).toEqual([]) }) + + it('explains that exited terminals count toward the quota and permits creation after closing one', async () => { + onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota')) + const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!) + for (let count = 1; count <= 2; count++) { + await openTerminal(page) + await command(page, 'exit') + await expect.poll(() => terminals().filter(info => info.state === 'exited').length).toBe(count) + } + await openTerminal(page, false) + const alert = page.getByRole('alert') + await expect.poll(async () => await alert.innerText()).toContain('Exited terminals also count toward the limit.') + const expectedLimit = fileURLToPath(new URL('./expected/sidebar-terminal/limit.expected.md', import.meta.url)) + await compareOrRefreshGolden(expectedLimit, await alert.ariaSnapshot(), webSnapshotMode()) + const failed = page.locator('[data-dockkit-tab][aria-selected="true"]') + await failed.hover() + await failed.locator('[data-dockkit-tab-close]').click() + const exited = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }).first() + await exited.hover() + await exited.locator('[data-dockkit-tab-close]').click() + await expect.poll(() => terminals().length).toBe(1) + await openTerminal(page) + await expect.poll(() => terminals().filter(info => info.state === 'running').length).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + }) + }) diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index eba346c0ea..4ede4369b5 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md -README.md: 08def8e53f6e9362b2bd99f03dc552115dcf9539 -README.zh.md: 4cd9d42272e4ff6a588305056450d145dc7dbb12 +README.md: 63da44832aa6087f43b9d9ecdafc56010f17245e +README.zh.md: abcaa83d41ff525d9e7a60ae49c27636a5c8b411 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index 08def8e53f..63da44832a 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -47,11 +47,11 @@ New terminals use the execution environment's declared default shell. Only when The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen. -The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. +The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed. -Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. +Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. -Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup; startup retries saved requests. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload. +Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload.
@@ -75,7 +75,7 @@ None; terminal output travels only between the browser and Host. -- Processes and screens survive browser reloads, but not Host or Session owner disposal. There is no durable terminal restoration or automatic shell respawn. +- Processes and screens survive browser reloads, but not Host or Session owner disposal. There is no durable terminal restoration or automatic shell respawn. Exited terminals count toward `maxTerminals`; close unused tabs to release their screens and quota. - The subprocess provider determines native PTY availability and process-tree cleanup guarantees. Finding an executable does not prove PTY allocation will succeed. - Screen recovery retains bounded history, not a complete transcript. Only one attachment at a time can write or resize. diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index 4cd9d42272..abcaa83d41 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -47,11 +47,11 @@ Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。 -最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 +最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。 -Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 +Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 -关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功后删除;启动时重试已保存的请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。 +关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。
@@ -75,7 +75,7 @@ Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作 -- 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。 +- 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。 已退出的终端仍计入 `maxTerminals`;关闭不用的标签页可释放其屏幕和名额。 - 原生 PTY 可用性和进程树清理保证由 subprocess provider 决定。找到可执行文件并不保证 PTY 分配成功。 - 屏幕恢复只保留有界历史,不保留完整记录。同一时刻只有一个连接可输入或调整尺寸。 diff --git a/packages/api/terminal-controller/src/client/index.ts b/packages/api/terminal-controller/src/client/index.ts index b49d86c437..1daec7edd8 100644 --- a/packages/api/terminal-controller/src/client/index.ts +++ b/packages/api/terminal-controller/src/client/index.ts @@ -1,5 +1,6 @@ /** Client terminal model service; views are keyed independently from Host terminal identities. */ import { Service, type Context } from '@deepseek-ai/cordis' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-api-gateway/client' import { TerminalView, type TerminalRemote } from './model.ts' @@ -8,7 +9,7 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { WebTerminalId, WebTerminalInfo } from '../types.ts' import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts' -export type { TerminalView, TerminalViewState, TerminalRenderFrame, TerminalRemote } from './model.ts' +export type { TerminalView, TerminalViewState, TerminalViewIssue, TerminalRenderFrame, TerminalRemote } from './model.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -42,9 +43,9 @@ export class ClientTerminals extends Service { super(ctx, 'webTerminals') ctx.effect(() => async () => { this.disposed = true - for (const views of this.views.values()) for (const view of views.values()) view.dispose() + const detaching = [...this.views.values()].flatMap(views => [...views.values()].map(view => view.dispose())) this.views.clear() - await Promise.all(this.closing.values()) + await Promise.all([...detaching, ...this.closing.values()]) }, 'terminal-controller.client.views') for (const request of this.requests.pending()) this.cleanup(request) } @@ -117,16 +118,20 @@ export class ClientTerminals extends Service { if (view !== undefined) await view.close() else { const result = await this.remote.close(record.sessionId, record.id) - if (!result.ok) throw new Error(result.error.message) + if (!result.ok) throw result.error } this.requests.remove(record.id) })().catch((error: unknown) => { + if (remoteErrorOf(error)?.code === 'session/not-found') { + this.requests.remove(record.id) + return + } if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), { id: record.id, title: record.title, message: error instanceof Error ? error.message : String(error), }]) - }).finally(() => { - view?.dispose() + }).then(async () => { + await view?.dispose() this.closing.delete(record.id) }) this.closing.set(record.id, pending) diff --git a/packages/api/terminal-controller/src/client/model.ts b/packages/api/terminal-controller/src/client/model.ts index 8c4336f3b9..7c19a4cac8 100644 --- a/packages/api/terminal-controller/src/client/model.ts +++ b/packages/api/terminal-controller/src/client/model.ts @@ -2,7 +2,7 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { @@ -13,6 +13,20 @@ import type { /** The generated terminal namespace's browser-facing operations. */ export type TerminalRemote = ClientRemote['terminal'] +/** Product error identifiers translated by the terminal UI. */ +export type TerminalViewIssue = 'missingTerminal' | 'inputFull' | 'attachmentEnded' | 'invalidOutput' | 'terminalLimit' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** Client terminal failure preserved through the Remote stream supervisor. */ + 'terminal/view': { readonly issue: TerminalViewIssue } + } +} + +class TerminalViewError extends RemoteError<'terminal/view'> { + constructor(issue: TerminalViewIssue, message: string = issue) { super('terminal/view', message, { issue }) } +} + /** One screen write awaiting the DOM emulator's callback. */ export interface TerminalRenderFrame { readonly revision: number @@ -28,6 +42,7 @@ export interface TerminalViewState { readonly writable: boolean readonly render?: TerminalRenderFrame | undefined readonly error?: string | undefined + readonly issue?: TerminalViewIssue | undefined } /** A view survives DOM unmount; its process only ends on explicit close. */ @@ -45,6 +60,7 @@ export class TerminalView { private closing: Promise | undefined private writes: Promise = Promise.resolve() private queuedInput = 0 + private readonly detaching = new Set>() /** * @param sessionId - Session owning the terminal. @@ -83,7 +99,7 @@ export class TerminalView { refresh(): Promise { if (this.loading !== undefined) return this.loading if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() - this.patch({ phase: 'loading', error: undefined }) + this.patch({ phase: 'loading', error: undefined, issue: undefined }) this.loading = (async () => { const [environment, available] = await Promise.all([ this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId), @@ -93,13 +109,13 @@ export class TerminalView { const info = valueOf(available).find(item => item.id === this.id) if (info !== undefined) this.adopt(info) else if (this.createWhenMissing) await this.create(valueOf(environment)) - else throw new Error('This terminal no longer exists on the Host. Open a new terminal.') + else throw new TerminalViewError('missingTerminal') })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined }) return this.loading } private async create(environment: TerminalEnvironment): Promise { - this.patch({ phase: 'creating', error: undefined }) + this.patch({ phase: 'creating', error: undefined, issue: undefined }) this.creation = (async () => { const info = valueOf(await this.remote.create(this.sessionId, { id: this.id, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows), @@ -126,11 +142,11 @@ export class TerminalView { this.attachmentId = attachmentId return this.remote.follow(this.sessionId, info.id, attachmentId, signal) }, - ended: () => new Error('Terminal attachment ended; reconnect to recover its state'), + ended: () => new TerminalViewError('attachmentEnded'), carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) }, }) this.stream = stream - this.patch({ phase: 'connecting', writable: false, error: undefined, render: undefined }) + this.patch({ phase: 'connecting', writable: false, error: undefined, issue: undefined, render: undefined }) void this.consume(stream) } @@ -153,11 +169,11 @@ export class TerminalView { const attachmentId = this.attachmentId if (!state.writable || state.info === undefined || attachmentId === undefined) return const bytes = new TextEncoder().encode(data).byteLength - if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new Error('Terminal input buffer is full')); return } + if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new TerminalViewError('inputFull')); return } this.queuedInput += bytes const id = state.info.id this.writes = this.writes.then(async () => { - if (this.attachmentId !== attachmentId) return + if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return valueOf(await this.remote.write(this.sessionId, id, attachmentId, data)) }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes }) } @@ -176,7 +192,7 @@ export class TerminalView { cols = Math.min(cols, state.environment?.maxCols ?? cols) rows = Math.min(rows, state.environment?.maxRows ?? rows) this.writes = this.writes.then(async () => { - if (this.attachmentId !== attachmentId) return + if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows)) }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }) } @@ -201,7 +217,7 @@ export class TerminalView { */ close(): Promise { if (this.closing !== undefined) return this.closing - this.patch({ phase: 'closing', writable: false, error: undefined }) + this.patch({ phase: 'closing', writable: false, error: undefined, issue: undefined }) this.detach() this.closing = (async () => { // Even a refused or lost creation response may leave an allocation to close. @@ -213,11 +229,15 @@ export class TerminalView { return this.closing } - /** Stop Client work on plugin unload without closing Host terminals. */ - dispose(): void { + /** + * Stop Client work on plugin unload without closing Host terminals. + * @returns after active and previously detached stream iterators have closed. + */ + async dispose(): Promise { this.mounted = false this.lifetime.abort() this.detach() + await Promise.all(this.detaching) } private detach(): void { @@ -226,7 +246,10 @@ export class TerminalView { this.attachmentId = undefined this.pendingRender?.resolve() this.pendingRender = undefined - if (previous !== undefined) void previous.dispose() + if (previous !== undefined) { + const cleanup = previous.dispose().finally(() => { this.detaching.delete(cleanup) }) + this.detaching.add(cleanup) + } } private async consume(stream: RemoteStream): Promise { @@ -237,14 +260,14 @@ export class TerminalView { if (this.stream !== stream) return const frame = item.value if (generation !== item.generation) { - if (frame.type !== 'snapshot') throw new Error('Terminal output generation is missing its screen snapshot') + if (frame.type !== 'snapshot') throw new TerminalViewError('invalidOutput', 'Terminal output generation is missing its screen snapshot') generation = item.generation sequence = frame.sequence item.accept() } else if (frame.type === 'output') { - if (frame.sequence !== sequence + 1) throw new Error('Terminal output sequence has a gap') + if (frame.sequence !== sequence + 1) throw new TerminalViewError('invalidOutput', 'Terminal output sequence has a gap') sequence = frame.sequence - } else if (frame.type === 'snapshot') throw new Error('Unexpected terminal screen snapshot') + } else if (frame.type === 'snapshot') throw new TerminalViewError('invalidOutput', 'Unexpected terminal screen snapshot') if (frame.type !== 'output') { this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId }) } @@ -274,11 +297,17 @@ export class TerminalView { } private fail(error: unknown): void { - this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, error: error instanceof Error ? error.message : String(error) }) + const failure = remoteErrorOf(error) + if (failure?.code === 'terminal/control-unavailable') { + this.patch({ writable: false, error: undefined, issue: undefined }) + return + } + const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : undefined + this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) }) } } function valueOf(result: RemoteResult): T { - if (!result.ok) throw new Error(result.error.message) + if (!result.ok) throw result.error return result.value } diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts index 9efb1d07df..d55771ae27 100644 --- a/packages/api/terminal-controller/src/index.ts +++ b/packages/api/terminal-controller/src/index.ts @@ -7,7 +7,7 @@ import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-session-projection' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { resolveShell } from './shells.ts' import { BrowserTerminal } from './terminal.ts' import type { @@ -153,7 +153,7 @@ export class TerminalController extends TypertRemoteService { return terminal.info } if (owner.allocations.has(request.id)) throw new Error('Close the failed terminal allocation before creating it again') - if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new Error('Session terminal limit reached') + if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: this.config.maxTerminals }) const allocation = this.spawn(agent, owner, request, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal])) owner.pending.set(request.id, allocation) try { @@ -232,6 +232,7 @@ export class TerminalController extends TypertRemoteService { async close(agent: Agent, id: WebTerminalId): Promise { const owner = this.owner(agent) owner.closedIds.add(id) + // create publishes the allocation before this wait settles; close owns it even if create then rejects. await owner.pending.get(id)?.catch(() => { /* Creation reports its failure; close still owns any allocated process. */ }) const terminal = owner.terminals.get(id) if (terminal !== undefined) { diff --git a/packages/api/terminal-controller/src/terminal.ts b/packages/api/terminal-controller/src/terminal.ts index 90688d9d78..9e8dfcafff 100644 --- a/packages/api/terminal-controller/src/terminal.ts +++ b/packages/api/terminal-controller/src/terminal.ts @@ -1,14 +1,21 @@ /** One PTY, a bounded terminal emulator and its detachable browser followers. */ import { createRequire } from 'node:module' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { Terminal as HeadlessTerminal } from '@xterm/headless' import type { SerializeAddon as Serializer } from '@xterm/addon-serialize' import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import { TerminalFollower } from './stream.ts' import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts' -// Literal createRequire calls keep CommonJS xterm entries reachable in the Preview image. -const { Terminal } = createRequire(import.meta.url)('@xterm/headless') as typeof import('@xterm/headless') -const { SerializeAddon } = createRequire(import.meta.url)('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize') +const { Terminal, SerializeAddon } = loadXterm() + +function loadXterm() { + // The Preview's CommonJS wrapper owns its outer require binding; these literal calls also retain the CJS entries. + const require = createRequire(import.meta.url) + const { Terminal } = require('@xterm/headless') as typeof import('@xterm/headless') + const { SerializeAddon } = require('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize') + return { Terminal, SerializeAddon } +} /** Process lifetime is independent of follower and component lifetimes. */ export class BrowserTerminal { @@ -125,8 +132,8 @@ export class BrowserTerminal { } private requireController(id: TerminalAttachmentId): void { - if (this.closing !== undefined || this.info.state !== 'running') throw new Error('Terminal is not running') - if (this.controller?.id !== id) throw new Error('Terminal input is controlled by another attachment') + if (this.closing !== undefined || this.info.state !== 'running') throw new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) + if (this.controller?.id !== id) throw new RemoteError('terminal/control-unavailable', 'Terminal input is controlled by another attachment', { reason: 'read-only' }) } private broadcast(frame: TerminalFrame): void { diff --git a/packages/api/terminal-controller/src/types.ts b/packages/api/terminal-controller/src/types.ts index 94cc827d4c..b474c9ad47 100644 --- a/packages/api/terminal-controller/src/types.ts +++ b/packages/api/terminal-controller/src/types.ts @@ -1,5 +1,15 @@ /** Browser terminal identities, metadata and screen-stream frames. */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type {} from '@deepseek-ai/dsh-typert-protocol' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** Input or resize was refused without invalidating the output attachment. */ + 'terminal/control-unavailable': { readonly reason: 'read-only' | 'not-running' } + /** Retained screens and pending allocations consume the Session's terminal quota. */ + 'terminal/limit-reached': { readonly limit: number } + } +} /** A terminal identity scoped to one Session and one Host lifetime. */ export type WebTerminalId = Branded<'WebTerminalId'> diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index 09859f9a8c..7910182675 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -210,7 +210,7 @@ describe('TerminalController', () => { const creating = controller.create(agent, request, signal()) try { await spawning.promise - await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toThrow('limit reached') + await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } }) } finally { allocation.resolve(handle) } const first = await creating expect(await controller.create(agent, request, signal())).toBe(first) @@ -225,7 +225,7 @@ describe('TerminalController', () => { await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') expect(controller.list(agent)).toMatchObject([{ id, state: 'failed', error: 'lost request' }]) await expect(controller.create(agent, request, signal())).rejects.toThrow('Close the failed terminal allocation') - await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toThrow('limit reached') + await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } }) await controller.close(agent, id) expect(controller.list(agent)).toEqual([]) }) diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts index 24be9f983d..4ce6fd6b8c 100644 --- a/packages/api/terminal-controller/tests/model.client.spec.ts +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -1,4 +1,5 @@ /** Browser view ownership across slow RPCs, remounts and transport generations. */ +import { setImmediate } from 'node:timers/promises' import { afterEach, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -53,7 +54,7 @@ function fixture(prepareStream?: (stream: RemoteStream) => void) { } cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) }) const model = new TerminalView(sessionId, remote, gateway, info.id) - cleanups.push(() => { model.dispose() }) + cleanups.push(() => model.dispose()) return { model, remote, gateway, options, streams, generation } } @@ -141,7 +142,7 @@ it('bounds queued input by UTF-8 bytes and releases the byte budget after settle model.write('界界') await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) model.write('a') - expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'Terminal input buffer is full' }) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' }) input.resolve(success(undefined)) model.connect() await expect.poll(() => model.state.getSnapshot().writable).toBe(true) @@ -227,7 +228,7 @@ it('reports an attachment ending while the shell is still running and permits ma yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } } }) await connected(model) - await expect.poll(() => model.state.getSnapshot().error).toContain('Terminal attachment ended') + await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded') model.connect() await expect.poll(() => model.state.getSnapshot().writable).toBe(true) }) @@ -407,7 +408,7 @@ it('keeps controls inert when process metadata or the attachment is unavailable' await expect.poll(() => remote.resize).toHaveBeenCalledOnce() expect(vi.mocked(remote.resize).mock.calls[0]?.slice(-2)).toEqual([100, 30]) model.write('unknown budget') - expect(model.state.getSnapshot().error).toBe('Terminal input buffer is full') + expect(model.state.getSnapshot().issue).toBe('inputFull') expect(remote.write).not.toHaveBeenCalled() }) @@ -421,7 +422,7 @@ it('renames an existing terminal, skips unchanged names, and exposes rename fail vi.mocked(remote.rename).mockRejectedValueOnce('rename connection lost') await model.rename('Other') expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'rename connection lost', title: 'Build' }) - model.dispose() + await model.dispose() await model.rename('Ignored') expect(remote.rename).toHaveBeenCalledTimes(2) }) @@ -446,7 +447,7 @@ it('retains state when disposal overtakes successful allocation or a failed disc else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise) const loading = model.refresh() if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce() - model.dispose() + await model.dispose() const before = model.state.getSnapshot() if (operation === 'creation') creation.resolve(success(info)) else discovery.reject(new Error('late discovery failure')) @@ -462,7 +463,7 @@ it('ignores a successful rename after the view is disposed', async () => { const rename = Promise.withResolvers>() vi.mocked(remote.rename).mockReturnValueOnce(rename.promise) const pending = model.rename('Late') - model.dispose() + await model.dispose() const before = model.state.getSnapshot() rename.resolve(success(undefined)) await pending @@ -492,7 +493,7 @@ it('does not publish a close result or reconnect after disposal', async () => { const closing = Promise.withResolvers>() vi.mocked(remote.close).mockReturnValueOnce(closing.promise) const pending = model.close() - model.dispose() + await model.dispose() const before = model.state.getSnapshot() closing.resolve(success(undefined)) await pending @@ -501,3 +502,60 @@ it('does not publish a close result or reconnect after disposal', async () => { expect(model.state.getSnapshot()).toBe(before) expect(remote.follow).toHaveBeenCalledOnce() }) + +it.each(['write', 'resize'] as const)('keeps the output connection when %s loses control before its state frame arrives', async (operation) => { + const { model, remote } = fixture() + const transfer = Promise.withResolvers() + const response = Promise.withResolvers>() + cleanups.push(() => { transfer.resolve(undefined); response.resolve(success(undefined)) }) + vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'retained screen', info: { ...info, id, controllerId } } + await transfer.promise + yield { type: 'state', info: { ...info, id } } + await untilAborted(signal) + }) + await connected(model) + vi.mocked(remote[operation]).mockReturnValueOnce(response.promise) + if (operation === 'write') model.write('before transfer') + else model.resize(100, 30) + model.write('queued before transfer') + await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1) + response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Another window owns input', { reason: 'read-only' }) }) + await expect.poll(() => model.state.getSnapshot().writable).toBe(false) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', error: undefined, issue: undefined, render: { frame: { screen: 'retained screen' } } }) + transfer.resolve(undefined) + await expect.poll(() => model.state.getSnapshot().info?.controllerId).toBeUndefined() + expect(remote.follow).toHaveBeenCalledOnce() + expect(vi.mocked(remote.write).mock.calls.some(call => call[3] === 'queued before transfer')).toBe(false) +}) + +it('keeps an exited screen when a pending input is refused after the exit state arrives', async () => { + const { model, remote } = fixture() + const exit = Promise.withResolvers() + const response = Promise.withResolvers>() + cleanups.push(() => { exit.resolve(undefined); response.resolve(success(undefined)) }) + vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + yield { type: 'snapshot', sequence: 0, screen: 'final screen', info: { ...info, id, controllerId } } + await exit.promise + yield { type: 'state', info: { ...info, id, state: 'exited', exitCode: 0 } } + await untilAborted(signal) + }) + await connected(model) + vi.mocked(remote.write).mockReturnValueOnce(response.promise) + model.write('exit race') + await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1) + exit.resolve(undefined) + await expect.poll(() => model.state.getSnapshot().info?.state).toBe('exited') + response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) }) + await setImmediate() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: false, error: undefined, issue: undefined, render: { frame: { screen: 'final screen' } } }) +}) + +it('exposes a localized quota error and clears it after a successful retry', async () => { + const { model, remote } = fixture() + vi.mocked(remote.create).mockResolvedValueOnce({ ok: false, error: new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: 8 }) }) + await model.refresh() + expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'terminalLimit' }) + await connected(model) + expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined }) +}) diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts index f9c2438fd2..28db262070 100644 --- a/packages/api/terminal-controller/tests/recovery.client.spec.ts +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -1,4 +1,5 @@ /** Reloaded tab identities and nonblocking close requests use independent lifetimes. */ +import { setImmediate } from 'node:timers/promises' import { afterEach, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -42,7 +43,7 @@ function fixture() { const gateway: Pick = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) } function view() { const model = new TerminalView(sessionId, remote, gateway, info.id) - cleanups.push(() => { model.dispose() }) + cleanups.push(() => model.dispose()) return model } async function service() { @@ -92,7 +93,7 @@ it('does not recreate a recovered terminal that disappeared after the recovery l const model = service.view(sessionId, 'recovered', info.id) await model.refresh() expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', writable: false }) - expect(model.state.getSnapshot().error).toBeDefined() + expect(model.state.getSnapshot().issue).toBe('missingTerminal') expect(h.remote.create).not.toHaveBeenCalled() model.mount() await model.refresh() @@ -152,7 +153,7 @@ it('updates a restored inactive terminal title and ignores late results after di const rename = Promise.withResolvers>() vi.mocked(h.remote.rename).mockReturnValueOnce(rename.promise) const pending = model.rename('Late') - model.dispose() + await model.dispose() rename.resolve(success(undefined)) await pending expect(model.state.getSnapshot().title).toBe('Build') @@ -367,3 +368,66 @@ it('keeps cleanup usable in memory when storage access itself is denied', () => expect(requests.pending()).toEqual([]) expect(error).toHaveBeenCalledTimes(3) }) + +it.each(['saved', 'view'] as const)('clears a %s close request after the Host confirms that its Session does not exist', async (source) => { + const data = storage() + const h = fixture() + const requests = new TerminalCloseRequests() + if (source === 'saved') requests.save({ sessionId, id: info.id, title: 'Build' }) + vi.mocked(h.remote.close).mockResolvedValue({ ok: false, error: new RemoteError('session/not-found', 'Deleted Session', { sessionId }) }) + const { service, dispose } = await h.service() + if (source === 'view') { + const view = service.view(sessionId, 'tab') + await view.refresh() + service.close(sessionId, 'tab') + } + await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1) + await dispose() + expect(data.size).toBe(0) + expect(service.closeFailures.getSnapshot()).toEqual([]) + expect(new TerminalCloseRequests().pending()).toEqual([]) + await h.service() + expect(h.remote.close).toHaveBeenCalledOnce() +}) + +it('waits for both active and detached stream finalizers during plugin disposal without closing Host processes', async () => { + const h = fixture() + const { service, dispose } = await h.service() + const started = [Promise.withResolvers(), Promise.withResolvers()] + const release = [Promise.withResolvers(), Promise.withResolvers()] + const finished = [Promise.withResolvers(), Promise.withResolvers()] + cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) }) + let index = 0 + vi.mocked(h.remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + const current = index++ + try { + yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } } + await new Promise((resolve) => { + if (signal?.aborted) resolve() + else signal?.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + started[current]!.resolve(undefined) + await release[current]!.promise + finished[current]!.resolve(undefined) + } + }) + const model = service.view(sessionId, 'tab') + model.mount() + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + model.connect() + await started[0]!.promise + await expect.poll(() => model.state.getSnapshot().writable).toBe(true) + let disposed = false + const disposing = dispose().then(() => { disposed = true }) + await started[1]!.promise + release[1]!.resolve(undefined) + await finished[1]!.promise + // Drain runnable disposal continuations; only the held old finalizer may keep teardown pending. + await setImmediate() + expect(disposed).toBe(false) + release[0]!.resolve(undefined) + await disposing + expect(disposed).toBe(true) + expect(h.remote.close).not.toHaveBeenCalled() +}) diff --git a/packages/api/terminal-controller/tests/terminal.spec.ts b/packages/api/terminal-controller/tests/terminal.spec.ts index 43c0446458..bf6bd16318 100644 --- a/packages/api/terminal-controller/tests/terminal.spec.ts +++ b/packages/api/terminal-controller/tests/terminal.spec.ts @@ -63,7 +63,7 @@ describe('BrowserTerminal', () => { output.write(encoded.subarray(2)) expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' }) const second = await attach(terminal, 'second') - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('another attachment') + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } }) await terminal.write(attachment('second'), '\t') expect(handle.write).toHaveBeenCalledWith('\t') await terminal.resize(attachment('second'), 100, 30) @@ -103,15 +103,15 @@ describe('BrowserTerminal', () => { it('rejects input before attachment, during close and after process exit', async () => { const { terminal, handle, output, outcome } = fixture() - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('another attachment') + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } }) const first = await attach(terminal) output.end() outcome.resolve({ exitCode: 0, signal: null }) expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } }) - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('not running') - await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('not running') + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) + await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) const closing = terminal.close() - await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('not running') + await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } }) await closing expect(handle.write).not.toHaveBeenCalled() expect(handle.resize).not.toHaveBeenCalled() diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 0b4a9bd86c..797bddd458 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md -README.md: 1a0a34119cc91f2e7c829e8b0c1dc56ad74d2631 -README.zh.md: 7d611eb4a4d4daa0c00baa8658b8b3e84288471e +README.md: 9278274ed8c4ab6e52890acd58d006c10788c053 +README.zh.md: ced37253897b43d7e6f74c850c75f844e855bb3b diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 1a0a34119c..9278274ed8 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -93,7 +93,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store and reconciles records on its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller. -Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler can save background cleanup and return synchronously, allowing immediate removal, or return a Promise that the sidebar awaits before explicit close or replacement. A thrown or rejected handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close. +Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler synchronously retains any background cleanup before allowing explicit close or replacement. The resource owner tracks completion and retry; the sidebar does not wait. A thrown handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close. ## The guide diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 7d611eb4a4..ced3725389 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -93,7 +93,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私有装配回调收养各会话的store,并在每次提交时对齐记录。记录消失或插件卸载才中止signal,收起和切会话不销毁记录;undo恢复的是新occurrence。`useTabInfo()` 组合框架绑定的store与导航hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。 -标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 可以保存后台清理任务并同步返回,让标签页立即移除;也可以返回 Promise,让侧栏在显式关闭或替换前等待。handler 抛错或拒绝时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。 +标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 在允许显式关闭或替换前同步保存后台清理任务。资源所有者跟踪完成和重试,侧栏不等待清理。handler 抛错时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。 ## 引导页 diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index 87fe12883f..f964ee97c6 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -126,7 +126,7 @@ export interface SidebarRightTabActions { */ openTab(kind: K, options?: SidebarRightTabPlacement & { readonly params?: SidebarRightTabParamsFor }): void /** Close this tab. */ - close(): void | Promise + close(): void } /** Live information shared by a tab's body, title, and guide replacement. */ diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 556296f186..5ffcbd34e1 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -163,8 +163,8 @@ export function apply(ctx: ClientContext): void { inject: (sessionId): SidebarRightInjected => ({ ...injected, closeTab: (tabId) => { - const pending = controller.closeIn(sessionId, tabId) - if (pending !== undefined) void pending.catch((error: unknown) => { console.error('Sidebar tab close failed:', error) }) + try { controller.closeIn(sessionId, tabId) } + catch (error) { console.error('Sidebar tab close failed:', error) } }, keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation }, occurrence: tab => controller.tabDomain.occurrence(sessionId, tab), diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index f68b2fe1f9..35086a46ca 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -134,8 +134,8 @@ export interface SidebarRightOpenTabOptions extends S /** The scheme every resource address carries; anything else is not a resource this face opens. */ const RESOURCE_SCHEME = 'dsh-resource://' -/** Explicit close/replacement hook. Return void for immediate removal, or a promise to defer removal until cleanup succeeds. */ -export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => void | Promise +/** Synchronous close/replacement hook; resource owners retain any background cleanup. */ +export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => void /** The outward right-Sidebar face (`ctx.sidebarRight`). */ export interface ISidebarRight { @@ -163,7 +163,7 @@ export interface ISidebarRight { * Close one tab of the mounted session; the sole docked guide remains open. * @param tabId - the tab to close. */ - close(tabId: TabId): void | Promise + close(tabId: TabId): void /** * The active tab of the active pane. * @returns the record, or `undefined` when no seat is mounted. @@ -207,12 +207,11 @@ export interface ISidebarRight { export class SidebarRightController implements ISidebarRight { private binding: SidebarRightBinding | undefined private readonly closeHandlers = new Map() - private readonly closing = new Map>() /** * Register resource cleanup before explicit removal. Failure preserves the tab. * @param kind - tab kind owned by the registering plugin. - * @param handler - saves background cleanup synchronously, or returns cleanup to await before removal. + * @param handler - saves any background cleanup before returning and allowing removal. * @returns an effect-scoped unregister callback. */ registerCloseHandler(kind: string, handler: SidebarRightCloseHandler): () => void { @@ -308,33 +307,19 @@ export class SidebarRightController implements ISidebarRight { * Not part of `ISidebarRight`: the Tab domain's path. * @param sessionId - the session the tab is in. * @param tabId - the tab to close. - * @returns resource cleanup completion when the tab type owns an asynchronous close handler. */ - closeIn(sessionId: SessionId, tabId: TabId): void | Promise { + closeIn(sessionId: SessionId, tabId: TabId): void { const actions = this.actionsFor(sessionId) const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] if (actions === undefined || surface === undefined) return const tab = surface.layout.tabs[tabId] if (tab === undefined || !canCloseTab(surface, tabId)) return - return this.removeAfterCleanup(sessionId, tab, () => { actions.closeTab(sessionId, tabId) }) + this.removeAfterCleanup(sessionId, tab, () => { actions.closeTab(sessionId, tabId) }) } - private removeAfterCleanup(sessionId: SessionId, tab: TabRecord, commit: () => void): void | Promise { - const handler = this.closeHandlers.get(tab.kind) - if (handler === undefined) { commit(); return } - const key = `${sessionId}:${tab.id}` - const existing = this.closing.get(key) - if (existing !== undefined) return existing - const adoption = this.adopted.get(sessionId) - const occurrence = this.tabDomain.occurrence(sessionId, tab) - const completion = handler(sessionId, tab) - if (completion === undefined) { commit(); return } - const pending = completion.then(() => { - if (this.adopted.get(sessionId) !== adoption || occurrence.signal.aborted) return - commit() - }).finally(() => { this.closing.delete(key) }) - this.closing.set(key, pending) - return pending + private removeAfterCleanup(sessionId: SessionId, tab: TabRecord, commit: () => void): void { + this.closeHandlers.get(tab.kind)?.(sessionId, tab) + commit() } /** Claim a resource and place it in one session; an address outside the scheme or one no type claims throws. */ @@ -385,18 +370,16 @@ export class SidebarRightController implements ISidebarRight { const revealed = layout === undefined || placement.revealIfOpened === false ? undefined : findContentTab(layout, claim.contentId, claim.kind) if (replaced === undefined || replaced.id === revealed) { commit(); return } - const pending = this.removeAfterCleanup(sessionId, replaced, commit) - if (pending !== undefined) void pending.catch((error: unknown) => { console.error('Sidebar tab replacement failed:', error) }) + this.removeAfterCleanup(sessionId, replaced, commit) } /** * Close one tab of the mounted session; the sole docked guide remains open. * @param tabId - the tab to close. - * @returns resource cleanup completion when the tab type owns an asynchronous close handler. */ - close(tabId: TabId): void | Promise { + close(tabId: TabId): void { const { sessionId, actions } = this.require() - if (this.adopted.has(sessionId)) return this.closeIn(sessionId, tabId) + if (this.adopted.has(sessionId)) { this.closeIn(sessionId, tabId); return } actions.closeTab(sessionId, tabId) } diff --git a/packages/client/ui-sidebar-right/src/client/tab-domain.ts b/packages/client/ui-sidebar-right/src/client/tab-domain.ts index 533cf866c4..53b259584e 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-domain.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-domain.ts @@ -34,7 +34,7 @@ export interface SidebarRightNavigator { /** Open a page type in one session; see `ISidebarRight.openTab`. */ openTabIn(sessionId: SessionId, kind: string, options?: SidebarRightOpenTabOptions): void /** Close a tab of one session. */ - closeIn(sessionId: SessionId, tabId: TabId): void | Promise + closeIn(sessionId: SessionId, tabId: TabId): void } /** `ctx.resources.pin`: hold an address's content open for as long as `signal` lives. */ @@ -175,7 +175,7 @@ export class TabDomain { openTab: (kind, options = {}) => { navigator.openTabIn(sessionId, kind, { ...place(options), params: options.params }) }, - close: () => navigator.closeIn(sessionId, tabId), + close: () => { navigator.closeIn(sessionId, tabId) }, }, } this.session(sessionId).set(tabId, held) diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index 6dbce4eea3..543a18f666 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -163,7 +163,7 @@ describe('ui-sidebar-right apply', () => { // Held and pinned from the store's own commit: no seat synced anything. const occurrence = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide) expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', occurrence.signal) - void occurrence.tabActions.close() + occurrence.tabActions.close() expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined() expect(occurrence.signal.aborted).toBe(true) }) @@ -199,7 +199,7 @@ describe('ui-sidebar-right apply', () => { await fiber.dispose() expect(signal.aborted).toBe(true) // The adoption went with the plugin: a late action from the dead occurrence changes nothing. - void tabActions.close() + tabActions.close() expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeDefined() expect(ctx.get('sidebarRight')).toBeUndefined() expect(ctx.get('sidebarRightTabs')).toBeUndefined() diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 2ad4b7adbe..274750463a 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -138,7 +138,7 @@ describe('RightbarSeat presentation', () => { if (entryCount !== 1) { expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) const before = h.layout() - act(() => { void h.controller.close(initial.id) }) + act(() => { h.controller.close(initial.id) }) expect(h.layout()).toBe(before) fireEvent.contextMenu(element(h.view.container, '[data-dockkit-tab]')) expect(document.querySelector('[data-dockkit-tab-menu] [role^="menuitem"]')).toBeNull() @@ -146,7 +146,7 @@ describe('RightbarSeat presentation', () => { return } expect(h.view.container.querySelector(`[data-dockkit-tab-close="${initial.id}"]`)).not.toBeNull() - act(() => { void h.controller.close(initial.id) }) + act(() => { h.controller.close(initial.id) }) expect(h.layout().expanded).toBe(false) const reseeded = Object.values(h.layout().tabs)[0]! expect(reseeded.kind).toBe('text') @@ -157,12 +157,12 @@ describe('RightbarSeat presentation', () => { expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${reseeded.id}"]`)).not.toBeNull() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).not.toBeNull() - act(() => { void h.controller.close(reseeded.id) }) + act(() => { h.controller.close(reseeded.id) }) expect(h.layout().tabs[reseeded.id]).toBeUndefined() expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).toBeNull() const preview = h.open('ordinary.txt') expect(h.view.container.querySelector(`[data-dockkit-tab-close="${preview.id}"]`)).not.toBeNull() - act(() => { void h.controller.close(preview.id) }) + act(() => { h.controller.close(preview.id) }) expect(h.layout().tabs[preview.id]).toBeUndefined() expect(Object.keys(h.layout().tabs)).toEqual([guide.id]) expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0) @@ -437,7 +437,7 @@ describe('slot-owned useTabInfo', () => { expect(Object.values(h.layout().tabs).map(tab => tab.title)).toContain('b.txt') expect(h.controller.active()?.contentId).toBe(otherTab.contentId) expect(h.bodies.get(otherTab.id)?.tab.navigation.params).toEqual({ line: 9 }) - act(() => { void info.tab.actions.close() }) + act(() => { info.tab.actions.close() }) expect(info.tab.signal.aborted).toBe(true) expect(otherInfo.tab.signal.aborted).toBe(false) await h.runtime.sessions.setCurrent(SESSION) @@ -580,11 +580,11 @@ describe('intentsFor — the kit\'s gestures as one session\'s store actions', ( }) }) -it('keeps a resource tab and reports cleanup rejection from its close button', async () => { +it('keeps a resource tab and reports a synchronous cleanup failure from its close button', async () => { const h = await mountSeat() const tab = h.open('terminal') const failure = new Error('process still running') - const release = h.controller.registerCloseHandler('text', async () => { throw failure }) + const release = h.controller.registerCloseHandler('text', () => { throw failure }) const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) try { fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${tab.id}"]`)) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 6827aa5824..cbbe814090 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -82,7 +82,7 @@ describe('SidebarRightController — opening', () => { const { controller } = harness() expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') expect(() => { controller.openTab('guide') }).toThrow('no session surface is mounted') - expect(() => { void controller.close('tab1' as TabId) }).toThrow('no session surface is mounted') + expect(() => { controller.close('tab1' as TabId) }).toThrow('no session surface is mounted') expect(() => { controller.toggleExpanded() }).toThrow('no session surface is mounted') expect(() => { controller.focus('tab1' as TabId) }).toThrow('no session surface is mounted') expect(() => { controller.split() }).toThrow('no session surface is mounted') @@ -283,7 +283,7 @@ describe('SidebarRightController — opening', () => { publish() controller.openResource('dsh-resource://file/session/s-test/a.txt') publish() - void controller.close(tabOf('a.txt')) + controller.close(tabOf('a.txt')) expect(titles()).not.toContain('a.txt') }) @@ -459,7 +459,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { // session's closes this session's, aborts its occurrence at once although // no seat draws the session, and the other keeps its own. expect(otherSurface.layout.tabs[guide.id]).toBeDefined() - void guideOccurrence.tabActions.close() + guideOccurrence.tabActions.close() expect(layout().tabs[guide.id]).toBeUndefined() expect(guideOccurrence.signal.aborted).toBe(true) expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface) @@ -479,14 +479,14 @@ describe('SidebarRightController — a tab\'s own actions', () => { const before = instance.getSnapshot().bySession tabActions.openResource(B_TXT) tabActions.openTab('guide') - void tabActions.close() + tabActions.close() expect(instance.getSnapshot().bySession).toBe(before) // Adopted, they land; released, they stop again. const release = adopt(SESSION, instance) tabActions.openResource(B_TXT) expect(titles()).toContain('b.txt') release() - void tabActions.close() + tabActions.close() expect(titles()).toContain('a.txt') }) @@ -591,12 +591,12 @@ describe('explicit tab resource cleanup', () => { const unregister = h.controller.registerCloseHandler('text', handler) try { const before = h.entries() - if (operation === 'close') expect(h.controller.close(tabId)).toBeUndefined() + if (operation === 'close') h.controller.close(tabId) else h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) expect(handler).toHaveBeenCalledExactlyOnceWith(SESSION, original) expect(h.layout().tabs[tabId]).toBeUndefined() expect(h.entries()).toBe(before + 1) - expect(h.controller.closeIn(SESSION, tabId)).toBeUndefined() + h.controller.closeIn(SESSION, tabId) expect(handler).toHaveBeenCalledOnce() } finally { unregister() @@ -605,44 +605,7 @@ describe('explicit tab resource cleanup', () => { } }) - it('awaits cleanup once before closing and never closes on collapse', async () => { - const h = harness() - h.adopt(SESSION, h.instance) - h.publish() - h.controller.openResource('dsh-resource://file/session/s-test/terminal') - h.publish() - const tabId = h.tabOf('terminal') - const cleanup = Promise.withResolvers() - const handler = vi.fn(() => cleanup.promise) - h.controller.registerCloseHandler('text', handler) - h.controller.toggleExpanded() - expect(handler).not.toHaveBeenCalled() - const first = h.controller.closeIn(SESSION, tabId) - const second = h.controller.closeIn(SESSION, tabId) - expect(first).toBe(second) - expect(handler).toHaveBeenCalledOnce() - expect(h.layout().tabs[tabId]).toBeDefined() - cleanup.resolve(undefined) - await first - expect(h.layout().tabs[tabId]).toBeUndefined() - }) - - it('keeps the tab after failed cleanup and permits retry', async () => { - const h = harness() - h.adopt(SESSION, h.instance) - h.publish() - h.controller.openResource('dsh-resource://file/session/s-test/terminal') - h.publish() - const tabId = h.tabOf('terminal') - const handler = vi.fn().mockRejectedValueOnce(new Error('still running')).mockResolvedValue(undefined) - h.controller.registerCloseHandler('text', handler) - await expect(h.controller.closeIn(SESSION, tabId)).rejects.toThrow('still running') - expect(h.layout().tabs[tabId]).toBeDefined() - await h.controller.closeIn(SESSION, tabId) - expect(h.layout().tabs[tabId]).toBeUndefined() - }) - - it('awaits cleanup before replacing in a single history entry, and skips cleanup when revealing the same tab', async () => { + it('does not invoke cleanup on collapse or when revealing the same tab', () => { const h = harness() h.adopt(SESSION, h.instance) h.publish() @@ -650,27 +613,39 @@ describe('explicit tab resource cleanup', () => { h.controller.openResource(address) h.publish() const tabId = h.tabOf('terminal') - const cleanup = Promise.withResolvers() - const handler = vi.fn(() => cleanup.promise) + const handler = vi.fn() h.controller.registerCloseHandler('text', handler) + h.controller.toggleExpanded() h.controller.openResource(address, { replaceTab: tabId }) expect(handler).not.toHaveBeenCalled() - const before = h.entries() - h.controller.openTab('guide', { replaceTab: tabId }) - expect(h.entries()).toBe(before) expect(h.layout().tabs[tabId]).toBeDefined() - cleanup.resolve(undefined) - await expect.poll(() => h.layout().tabs[tabId]).toBeUndefined() - expect(h.entries()).toBe(before + 1) + }) + + it.each(['close', 'replace'] as const)('preserves a tab when its synchronous %s handler throws', (operation) => { + const h = harness() + h.adopt(SESSION, h.instance) + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/terminal') + const tabId = h.tabOf('terminal') + const release = h.controller.registerCloseHandler('text', () => { throw new Error('cannot retain cleanup') }) + const perform = () => { + if (operation === 'close') h.controller.closeIn(SESSION, tabId) + else h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) + } + expect(perform).toThrow('cannot retain cleanup') + expect(h.layout().tabs[tabId]).toBeDefined() + release() + perform() + expect(h.layout().tabs[tabId]).toBeUndefined() }) }) -it('releases close handlers without a stale disposer removing a replacement', async () => { +it('releases close handlers without a stale disposer removing a replacement', () => { const h = harness() h.adopt(SESSION, h.instance) h.publish() - const first = vi.fn(async () => {}) - const second = vi.fn(async () => {}) + const first = vi.fn(() => {}) + const second = vi.fn(() => {}) const release = h.controller.registerCloseHandler('text', first) expect(() => h.controller.registerCloseHandler('text', second)).toThrow('already registered') release() @@ -678,7 +653,7 @@ it('releases close handlers without a stale disposer removing a replacement', as release() h.controller.openResource('dsh-resource://file/session/s-test/one') h.publish() - await h.controller.closeIn(SESSION, h.tabOf('one')) + h.controller.closeIn(SESSION, h.tabOf('one')) expect(first).not.toHaveBeenCalled() expect(second).toHaveBeenCalledOnce() releaseSecond() @@ -689,41 +664,3 @@ it('releases close handlers without a stale disposer removing a replacement', as expect(h.layout().tabs[tabId]).toBeUndefined() expect(second).toHaveBeenCalledOnce() }) - -it.each(['adoption', 'occurrence'] as const)('does not commit a pending close after its %s ends', async (lifetime) => { - const h = harness() - const release = h.adopt(SESSION, h.instance) - h.publish() - h.controller.openResource('dsh-resource://file/session/s-test/terminal') - h.publish() - const tabId = h.tabOf('terminal') - const cleanup = Promise.withResolvers() - h.controller.registerCloseHandler('text', () => cleanup.promise) - const pending = h.controller.closeIn(SESSION, tabId) - if (lifetime === 'adoption') release() - else h.instance.actions.closeTab(SESSION, tabId) - const entries = h.entries() - cleanup.resolve(undefined) - await pending - expect(h.entries()).toBe(entries) - if (lifetime === 'adoption') expect(h.layout().tabs[tabId]).toBeDefined() -}) - -it('reports failed replacement cleanup and retains the original resource', async () => { - const h = harness() - h.adopt(SESSION, h.instance) - h.publish() - h.controller.openResource('dsh-resource://file/session/s-test/terminal') - h.publish() - const tabId = h.tabOf('terminal') - const error = new Error('process cleanup failed') - h.controller.registerCloseHandler('text', async () => { throw error }) - const reported = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false }) - await expect.poll(() => reported.mock.calls).toEqual([['Sidebar tab replacement failed:', error]]) - expect(h.layout().tabs[tabId]).toBeDefined() - } finally { - reported.mockRestore() - } -}) diff --git a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts index c51e9ca64e..793bda8083 100644 --- a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts @@ -191,7 +191,7 @@ describe('TabDomain — a tab\'s own actions', () => { const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId)) tabActions.openTab('files', { replaceTab: true }) expect(navigator.openTabIn).toHaveBeenLastCalledWith(SESSION, 'files', { replaceTab: tabId }) - void tabActions.close() + tabActions.close() expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId) }) diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml index cae62f6540..769eb9afc0 100644 --- a/packages/client/ui-sidebar-terminal/README.i18n.yaml +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md -README.md: cf915dc73c9009b4b91d084d9b7aee466e4f2c75 -README.zh.md: a7aa321d106d49061ae7b092f6e116cab153627c +README.md: 2dba3ea839f89f505286fa6b977aef3fb0e45ab0 +README.zh.md: 63d483a37fcb9b913c20e47251aaaba3e0e21ffd diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md index cf915dc73c..2dba3ea839 100644 --- a/packages/client/ui-sidebar-terminal/README.md +++ b/packages/client/ui-sidebar-terminal/README.md @@ -27,7 +27,7 @@ Open the execution environment's default shell beside a conversation without lea Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. The default shell starts immediately. Use the sidebar's **New tab** control to open more terminals. -Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. +Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached. Closing or replacing a terminal tab removes it immediately and ends its process in the background. A cleanup failure shows a small notification with **Retry**; retrying does not reopen the tab. Collapsing, switching tabs or Sessions, floating and fullscreen presentation preserve the process. diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md index a7aa321d10..63d483a37f 100644 --- a/packages/client/ui-sidebar-terminal/README.zh.md +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。默认 shell 会立即启动。通过侧栏的**新标签页**控件打开更多终端。 -双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 +双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 已退出的终端计入 Session 数量上限;达到上限时请关闭不用的标签页。 关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败时显示带**重试**操作的轻量通知;重试不会重新打开标签页。折叠、切换标签页或 Session、浮动和全屏都保留进程。 diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx index b9916e0935..9a14a55a14 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx @@ -23,7 +23,7 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP const state = useTerminal(tab.id) useEffect(() => model.mount(), [model]) if (state === undefined) return null - const error = state.error ?? state.info?.error + const error = state.issue === undefined ? state.error ?? state.info?.error : t(state.issue) let status: string | undefined if (state.phase === 'idle' || state.phase === 'loading') status = t('loading') else if (state.phase === 'creating' || state.phase === 'connecting' || state.phase === 'disconnected') status = t(state.phase) diff --git a/packages/client/ui-sidebar-terminal/src/client/locales.ts b/packages/client/ui-sidebar-terminal/src/client/locales.ts index cdaad90ecc..ebda63a422 100644 --- a/packages/client/ui-sidebar-terminal/src/client/locales.ts +++ b/packages/client/ui-sidebar-terminal/src/client/locales.ts @@ -16,6 +16,11 @@ export const zh = { closed: '终端已关闭。', exited: '进程已退出({code})', failed: '终端错误:{message}', rename: '终端名称', unavailable: '不可用', retry: '重试', cleanupFailed: '终端「{title}」未能结束:{message}', + missingTerminal: '此终端已不存在,请新建终端。', + inputFull: '输入缓冲区已满,请重新连接后重试。', + attachmentEnded: '终端连接已结束,请重新连接。', + invalidOutput: '终端画面传输异常,请重新连接。', + terminalLimit: '终端数量已达上限,请关闭不用的终端后重试。已退出的终端也计入数量。', } satisfies Record /** English terminal copy. */ @@ -27,4 +32,9 @@ export const en = { closed: 'Terminal closed.', exited: 'Process exited ({code})', failed: 'Terminal error: {message}', rename: 'Terminal name', unavailable: 'Unavailable', retry: 'Retry', cleanupFailed: 'Terminal “{title}” could not be ended: {message}', + missingTerminal: 'This terminal no longer exists. Open a new terminal.', + inputFull: 'The input buffer is full. Reconnect and try again.', + attachmentEnded: 'The terminal connection ended. Reconnect to continue.', + invalidOutput: 'The terminal screen could not be received. Reconnect to recover it.', + terminalLimit: 'The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit.', } satisfies Record diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts index 21f616a07d..3fe6dd4e4d 100644 --- a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -87,10 +87,10 @@ it('registers terminal views, recovery and cleanup, then releases every contribu expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId) expect(h.occurrence).toHaveBeenLastCalledWith(sessionId, { id: 'tab' }) if (h.closeHandler === undefined) throw new Error('Terminal close handler was not registered') - expect(h.closeHandler(sessionId, { id: 'tab' } as Parameters[1])).toBeUndefined() + h.closeHandler(sessionId, { id: 'tab' } as Parameters[1]) expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId) h.setParams(undefined) - expect(h.closeHandler(sessionId, { id: 'new-tab' } as Parameters[1])).toBeUndefined() + h.closeHandler(sessionId, { id: 'new-tab' } as Parameters[1]) expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'new-tab', undefined) const cleanupFace = h.entries[3]!.inject(sessionId) as TerminalCleanupInjected expect(cleanupFace.hooks.closeFailures).toBe(h.terminals.closeFailures) diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx index 8f89680527..c20a5e7920 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx @@ -60,7 +60,7 @@ const titleSurfaces = [ wrap: (title: ReactNode) =>
{title}
, }, ] -function mount(initial: TerminalViewState | undefined = idle) { +function mount(initial: TerminalViewState | undefined = idle, dictionary = en) { let state: TerminalViewState | undefined = initial let visible = true const detach = vi.fn() @@ -74,7 +74,7 @@ function mount(initial: TerminalViewState | undefined = idle) { const props = { view: () => model, useTerminal: (_key: string, select?: (value: TerminalViewState | undefined) => unknown) => select === undefined ? state : select(state), - useTabInfo: tab, t: makeTranslate(en), + useTabInfo: tab, t: makeTranslate(dictionary), } as unknown as TerminalBodyProps const view = render() return { @@ -297,3 +297,15 @@ it('starts a recovered screen with no local history when environment discovery i mount({ ...idle, info, environment: undefined }) expect(fake.terminals[0]!.options).toHaveProperty('scrollback', 0) }) + + +it.each([en, zh])('translates known terminal failures while retaining unknown Host diagnostics', (dictionary) => { + const h = mount(idle, dictionary) + for (const issue of ['missingTerminal', 'inputFull', 'attachmentEnded', 'invalidOutput', 'terminalLimit'] as const) { + h.update({ ...idle, phase: 'failed', issue, error: 'raw diagnostic' }) + expect(h.view.getByRole('alert').textContent).toContain(dictionary[issue]) + expect(h.view.getByRole('alert').textContent).not.toContain('raw diagnostic') + } + h.update({ ...idle, phase: 'failed', error: 'Host permission denied' }) + expect(h.view.getByRole('alert').textContent).toContain('Host permission denied') +}) diff --git a/packages/e2b/subprocess-e2b/README.i18n.yaml b/packages/e2b/subprocess-e2b/README.i18n.yaml index aa75116ae8..934fe327cb 100644 --- a/packages/e2b/subprocess-e2b/README.i18n.yaml +++ b/packages/e2b/subprocess-e2b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/e2b/subprocess-e2b/README.md -README.md: 11cb60e3966b2dd7bd20021a9cb6e3806b41df3e -README.zh.md: 14834748bbc82d87d3178ab75c7b470184b4e8c3 +README.md: 344abc6b8c9c26306696d59d9a56415eb69fbd3d +README.zh.md: 1a96f3a85da34349f02a78bcd7b51dbeb35e025c diff --git a/packages/e2b/subprocess-e2b/README.md b/packages/e2b/subprocess-e2b/README.md index 11cb60e396..344abc6b8c 100644 --- a/packages/e2b/subprocess-e2b/README.md +++ b/packages/e2b/subprocess-e2b/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Use this package when the agent's shell commands and terminals should run inside the remote sandbox rather than on your machine. It is the command half of the E2B family: commands, terminals, and files share one remote world. +Use this package when the agent's shell commands and terminals should run inside the remote sandbox rather than on your machine. It is the command half of the E2B family: commands, terminals, and files share one remote world. `terminalEnvironment()` reports the remote sandbox’s nonempty `SHELL`; an empty or absent value leaves shell fallback to the consumer. ### When to choose it diff --git a/packages/e2b/subprocess-e2b/README.zh.md b/packages/e2b/subprocess-e2b/README.zh.md index 14834748bb..1a96f3a85d 100644 --- a/packages/e2b/subprocess-e2b/README.zh.md +++ b/packages/e2b/subprocess-e2b/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -当 agent 的 shell 命令与终端应在远程沙箱内而非你的机器上运行时,使用本包。它是 E2B 家族的命令半边:命令、终端与文件共享同一个远程世界。 +当 agent 的 shell 命令与终端应在远程沙箱内而非你的机器上运行时,使用本包。它是 E2B 家族的命令半边:命令、终端与文件共享同一个远程世界。 `terminalEnvironment()` 返回远端沙箱中非空的 `SHELL`;空值或缺失值由消费者决定 shell 回退。 ### 何时选择 diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index 31d7e1bd54..4957aea42a 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -150,7 +150,7 @@ export class E2BSubprocessRuntime extends SubprocessRuntime { async terminalEnvironment(signal?: AbortSignal): Promise { const sandbox = await this.ctx.e2b.getSandbox() const environment = scrubRemoteEnvironment(await readRemoteEnvironment(sandbox, signal)) - const defaultShell = environment.get('SHELL') + const defaultShell = environment.get('SHELL') || undefined return { platform: 'posix', ...defaultShell === undefined ? {} : { defaultShell } } } diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index e726269502..e65e5226a2 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -859,6 +859,8 @@ describe('E2B subprocess terminal service', () => { try { fake.ambient = 'SHELL=/opt/fish\0' await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/opt/fish' }) + fake.ambient = 'SHELL=\0' + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) fake.ambient = 'PATH=/usr/bin\0' await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) fake.commandFailure = new Error('sandbox disconnected') diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 3cef0557d1..5f2f9dc7c4 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md -README.md: 11fd4cbcd4150cae75f15d5b5cc2d5294c2576ae -README.zh.md: 4bc0bc284770b68ef9cd8c6d09f39ccc8866d0f2 +README.md: 27cbdd6973b0b829e7448b3db131ae1041038c68 +README.zh.md: 564c3cc1947e97b9cb4ae2d68c1c0c77bcc0d104 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 11fd4cbcd4..27cbdd6973 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -29,7 +29,7 @@ Mount the provider beside its consumers and start processes exactly as the subpr ### Mounting the provider -Load the provider in the same composition as its consumers. It has no config fields: every choice arrives on the spawn request, so deployment-varying decisions stay with the caller's configuration. +Load the provider in the same composition as its consumers. It has no config fields: every choice arrives on the spawn request, so deployment-varying decisions stay with the caller's configuration. `terminalEnvironment()` reads a nonempty `SHELL` on POSIX, falling back to the account login shell, or a nonempty `ComSpec` on Windows. Empty values are omitted so the consumer can choose its platform fallback. ```yaml - name: '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 4bc0bc2847..564c3cc194 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 挂载提供方 -在与消费方相同的组合中加载本提供方。它没有任何配置字段:每项选择都随 spawn 请求到达,因此随部署变化的决策留在调用方的配置里。 +在与消费方相同的组合中加载本提供方。它没有任何配置字段:每项选择都随 spawn 请求到达,因此随部署变化的决策留在调用方的配置里。 `terminalEnvironment()` 在 POSIX 读取非空的 `SHELL`,缺失时使用账户登录 shell;在 Windows 读取非空的 `ComSpec`。空值会被省略,由消费者选择平台回退。 ```yaml - name: '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 85d80f4465..e7307dce7b 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -233,7 +233,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { async terminalEnvironment(signal?: AbortSignal): Promise { signal?.throwIfAborted() const platform = process.platform === 'win32' ? 'windows' : 'posix' - const defaultShell = platform === 'windows' ? process.env.ComSpec : process.env.SHELL ?? userInfo().shell ?? undefined + const defaultShell = platform === 'windows' ? process.env.ComSpec || undefined : process.env.SHELL || userInfo().shell || undefined return { platform, ...defaultShell === undefined ? {} : { defaultShell } } } diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 813f24d071..644bf16f5e 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -64,6 +64,10 @@ describe('LocalSubprocessRuntime', () => { await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/account/shell', }) + vi.stubEnv('SHELL', '') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/account/shell' }) + loginShell = '' + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) loginShell = null await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' }) platform.mockReturnValue('win32') @@ -73,6 +77,8 @@ describe('LocalSubprocessRuntime', () => { }) vi.stubEnv('ComSpec', undefined) await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'windows' }) + vi.stubEnv('ComSpec', '') + await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'windows' }) platform.mockReturnValue('linux') userInfo.mockClear() const reason = new Error('terminal inspection cancelled') From f0810edd7fc6a6467d3ee7d25b6c07d3deff9d9e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:19:51 +0800 Subject: [PATCH 14/38] fix(trajectory): expand preview thinking by default --- packages/client/ui-trajectory/src/client/TrajectoryTable.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index eb743e5881..67cf0d838d 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -2084,8 +2084,6 @@ export function TrajectoryTable({ ? thinkingDisclosure.expanded : selected?.cell.kind === 'message' && Boolean(selected.cell.thinkingDetail?.trim()) - && !selected.cell.outputDetail?.trim() - && !selected.cell.sourceBlocks?.some(block => block.type === 'tool-call') const setThinkingExpanded = (expanded: boolean) => { if (selectedRecordId !== null) { setThinkingDisclosure({ recordId: selectedRecordId, expanded }) From f36e1a3d1d1f4c291b010636b84eb2ea8bf8aca5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:20:13 +0800 Subject: [PATCH 15/38] fix(trajectory): show turn and ordinal step in detail titles --- packages/client/ui-trajectory/src/client/TrajectoryTable.tsx | 2 +- packages/client/ui-trajectory/src/client/locales.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 67cf0d838d..f7759adf00 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -3006,7 +3006,7 @@ export function TrajectoryTable({ {selected.cell.kind === 'compacted' ? sectionLabel(selected.turn, t) - : selected.group} + : `${sectionLabel(selected.turn, t)} · ${selected.group}`} )} diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index c2fbe6b25c..7e76444ca1 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -37,7 +37,7 @@ export const zh = { 'turn.label': '第 {turn} 轮', 'section.betweenTurns': '轮次之间', 'group.message': '消息', - 'group.step': '步骤 {step}', + 'group.step': '第 {step} 步', 'group.compaction': '压缩 {seq}', 'status.failed': '失败', 'status.pending': '等待中', From a73aca1978c22adbd8927ce08472cc1d9628b7c8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:34:11 +0800 Subject: [PATCH 16/38] test(client): cover assistant thinking disclosure defaults --- .../tests/reasoning-row.client.spec.tsx | 31 +++++++++++++++++-- .../ui-trajectory/tests/table.client.spec.tsx | 26 ++++++++++++---- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index 439b79bc7a..0711d89770 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -14,6 +14,26 @@ const t = makeTranslate(zh, commonZh) const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null describe('ReasoningRow', () => { + it('collapses when body text arrives and preserves a later manual expansion', () => { + const reasoning = { kind: 'reasoning' as const, text: 'Inspect the session\nCheck persistence' } + const view = render( + , + ) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') + view.rerender( + , + ) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') + fireEvent.click(view.getByText('思考')) + view.rerender( + , + ) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') + expect(view.getByText(/Check persistence/)).toBeTruthy() + }) + it('follows the latest streaming line, then restores the settled first line', () => { const view = render( { />, ) expect(view.getByText('运行中')).toBeTruthy() + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') + fireEvent.click(view.getByText('思考')) expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end')) .toBe('true') @@ -62,6 +84,7 @@ describe('ReasoningRow', () => { ) const row = view.getByRole('button') + fireEvent.click(view.getByText('思考')) fireEvent.click(view.getByText('Inspect the session')) expect(row.getAttribute('aria-expanded')).toBe('true') expect(view.getByText(/Check persistence/)).toBeTruthy() @@ -91,6 +114,7 @@ describe('ReasoningRow', () => { />, ) + fireEvent.click(view.getByText('思考')) expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy() expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull() @@ -98,7 +122,7 @@ describe('ReasoningRow', () => { expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text) }) - it('expanded Think drops the inline summary and renders plain prose, no IN card', () => { + it('opens reasoning-only replies as plain prose and allows manual collapse', () => { const view = render( { renderMessageImages={renderMessageImages} />, ) - fireEvent.click(view.getByText('思考')) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') expect(view.getAllByText(/Inspect the session/)).toHaveLength(1) expect(view.queryByText('IN')).toBeNull() expect(view.container.querySelector('[class*="ioCard"]')).toBeNull() expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull() + fireEvent.click(view.getByText('思考')) + expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(view.queryByText(/Check persistence/)).toBeNull() }) }) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index b16475162a..a60a53a1c0 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -231,7 +231,12 @@ describe('TrajectoryTable', () => { expect(panel.querySelector('[data-summary-scroll-region]')).toBeNull() }) - it('keeps long thinking collapsed until the user asks to render it', () => { + it.each([ + { outputDetail: undefined, toolCall: false }, + { outputDetail: 'Visible answer', toolCall: false }, + { outputDetail: undefined, toolCall: true }, + { outputDetail: 'Visible answer', toolCall: true }, + ])('opens thinking with output=$outputDetail and toolCall=$toolCall', ({ outputDetail, toolCall }) => { const thinking = 'private chain '.repeat(1_000) const turns: readonly TrajectoryTurnModel[] = [{ turn: 1, @@ -242,6 +247,10 @@ describe('TrajectoryTable', () => { kind: 'message', text: 'private chain…', thinkingDetail: thinking, + outputDetail, + sourceBlocks: toolCall ? [{ + type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read', + }] : undefined, timeSeconds: 1, }], }], @@ -249,14 +258,19 @@ describe('TrajectoryTable', () => { render() fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Preview' })) const toggle = screen.getByRole('button', { name: 'Thinking' }) - expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText(thinking)).toBeNull() - - fireEvent.click(toggle) - expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length) + + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText(thinking)).toBeNull() + fireEvent.click(screen.getByRole('tab', { name: 'Summary' })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('tab', { name: 'Preview' })) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true') }) it('keeps raw HTML tags in a Markdown-derived context preview', () => { From 5230c6052572af05ceb80fe301a23a1c79dd7862 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:34:11 +0800 Subject: [PATCH 17/38] test(trajectory): expect ordinal step detail titles --- packages/client/ui-trajectory/tests/views.client.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 79a0e14b0e..d0a00549d4 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -538,7 +538,7 @@ describe('tab switching in ConversationRoot', () => { fireEvent.keyDown(screen.getByRole('row', { name: /工具/ }), { key: 'Enter' }) expect(screen.getByRole('complementary', { name: '事件详情' })).toBeTruthy() - expect(screen.getByText('第 1 轮 · 步骤 1')).toBeTruthy() + expect(screen.getByText('第 1 轮 · 第 1 步')).toBeTruthy() expect(screen.getByText('已完成')).toBeTruthy() expect(screen.getByRole('tab', { name: '结果' })).toBeTruthy() From 9c6b282535675cf2a1d62c1e07a346d6425b046c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:34:12 +0800 Subject: [PATCH 18/38] test(client): cover raw JSON strings and wrapping preferences --- .../tests/json-tree.client.spec.tsx | 86 ++++++++++++------- .../string-wrapping-store.client.spec.ts | 26 ++++++ 2 files changed, 83 insertions(+), 29 deletions(-) create mode 100644 packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts diff --git a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx index 691ba88652..2279b5cb6e 100644 --- a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx +++ b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx @@ -13,8 +13,10 @@ function JsonTree(props: Omit, 'label' } let writeText: ReturnType +let originalClipboard: PropertyDescriptor | undefined beforeEach(() => { + originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -25,9 +27,62 @@ beforeEach(() => { afterEach(() => { cleanup() vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + if (originalClipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', originalClipboard) }) describe('JsonTree', () => { + it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => { + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(200) + const computedStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => { + const style = computedStyle(element) + style.lineHeight = '16px' + return style + }) + vi.stubGlobal('ResizeObserver', class { + observe() {} + disconnect() {} + }) + let wrapped = false + const stringWrapping = { + label: 'Wrap lines', + getDefault: () => wrapped, + setDefault: (value: boolean) => { wrapped = value }, + } + const original = ` leading spaces\n\t"quoted" \\${'long'.repeat(100)}\nlast line\n` + render() + const [first, second] = within(screen.getByRole('tree')).getAllByRole('treeitem') + const a = within(first as HTMLElement) + const b = within(second as HTMLElement) + fireEvent.click(a.getByRole('button', { name: 'Expand JSON node' })) + fireEvent.click(b.getByRole('button', { name: 'Expand JSON node' })) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(a.getByRole('button', { name: 'Wrap lines' })) + expect(wrapped).toBe(true) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(b.getByRole('button', { name: 'Collapse JSON node' })) + fireEvent.click(b.getByRole('button', { name: 'Expand JSON node' })) + expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + fireEvent.click(b.getByRole('button', { name: 'Wrap lines' })) + expect(wrapped).toBe(false) + expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true') + + fireEvent.click(a.getByRole('button', { name: 'Collapse JSON node' })) + fireEvent.click(a.getByRole('button', { name: 'Expand JSON node' })) + const toggle = a.getByRole('button', { name: 'Wrap lines' }) + expect(toggle.getAttribute('aria-pressed')).toBe('false') + const contents = document.getElementById(toggle.getAttribute('aria-controls') as string) + expect(contents?.textContent).toBe(original) + fireEvent.click(a.getByRole('button', { name: 'Copy value' })) + await waitFor(() => { expect(writeText).toHaveBeenCalledWith(original) }) + }) + it('keeps the top level open and renders expandable value previews', () => { render( { view.unmount() }) - it('keeps copy placement synchronized and clears stale targets', () => { + it('keeps the copy action on its hovered row and clears stale targets', () => { const view = render() const root = view.container.firstElementChild as HTMLElement const tree = screen.getByRole('tree') const firstRow = within(tree).getAllByRole('treeitem')[0] as HTMLElement const secondRow = within(tree).getAllByRole('treeitem')[1] as HTMLElement - Object.defineProperty(root, 'clientHeight', { configurable: true, value: 100 }) - Object.defineProperty(root, 'clientWidth', { configurable: true, value: 300 }) - vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ - bottom: 100, - height: 100, - left: 10, - right: 310, - top: 0, - width: 300, - x: 10, - y: 0, - toJSON: () => ({}), - }) - vi.spyOn(firstRow, 'getBoundingClientRect').mockReturnValue({ - bottom: 91, - height: 16, - left: 10, - right: 200, - top: 75, - width: 190, - x: 10, - y: 75, - toJSON: () => ({}), - }) - fireEvent.mouseOver(firstRow) const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' }) - expect((copyButton.closest('span')?.parentElement as HTMLElement).style.left).toBe('284px') + expect(firstRow.contains(copyButton)).toBe(true) fireEvent.mouseOver(copyButton) expect(screen.getByRole('button', { name: 'Copy pretty JSON' })).toBeDefined() fireEvent.mouseOver(firstRow) fireEvent.scroll(root) - fireEvent.scroll(window) - fireEvent.resize(window) fireEvent.contextMenu(copyButton) fireEvent.mouseOver(secondRow) diff --git a/packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts b/packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts new file mode 100644 index 0000000000..7ab269f2cf --- /dev/null +++ b/packages/client/ui-trajectory/tests/string-wrapping-store.client.spec.ts @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it } from 'vitest' +import { createTrajectoryStringWrappingStore } from '../src/client/string-wrapping-store.ts' + +const key = 'dsh.trajectory.jsonStringWrapping' +let original: string | null + +beforeEach(() => { + original = localStorage.getItem(key) + localStorage.removeItem(key) +}) + +afterEach(() => { + if (original === null) localStorage.removeItem(key) + else localStorage.setItem(key, original) +}) + +it('restores the last wrapping choice when the view is recreated', () => { + const first = createTrajectoryStringWrappingStore() + expect(first.getSnapshot()).toBe(false) + first.set(true) + const second = createTrajectoryStringWrappingStore() + expect(second.getSnapshot()).toBe(true) + second.set(false) + expect(createTrajectoryStringWrappingStore().getSnapshot()).toBe(false) +}) From 508490b531324bb611ae91463b1ed4874d66934c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:35:07 +0800 Subject: [PATCH 19/38] test(trajectory): omit absent optional fixture fields --- packages/client/ui-trajectory/tests/table.client.spec.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index a60a53a1c0..9723935bd4 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -247,10 +247,10 @@ describe('TrajectoryTable', () => { kind: 'message', text: 'private chain…', thinkingDetail: thinking, - outputDetail, - sourceBlocks: toolCall ? [{ + ...(outputDetail === undefined ? {} : { outputDetail }), + ...(toolCall ? { sourceBlocks: [{ type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read', - }] : undefined, + }] } : {}), timeSeconds: 1, }], }], From a9b5faef0b3cad409ea3331105fdb571823416bd Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 18:49:03 +0800 Subject: [PATCH 20/38] test(chat): collapse live thinking before checking its tail --- apps/web/tests/lifecycle-chrome.e2e.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index f42337be37..d5af569cbc 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -227,7 +227,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () try { await input.press('Enter') if (MODE !== 'record') { - const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]') + const thinking = page.locator('[data-variant="think"][data-state="running"]') + const disclosure = thinking.getByRole('button') + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') + const liveTail = thinking.locator('[data-follow-end]') await expect.poll(async () => { if (await liveTail.count() !== 1) return false return await liveTail.evaluate((element) => { From 573b0443207a7d214255b85e30f35db2ee815a34 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 19:52:42 +0800 Subject: [PATCH 21/38] refactor(client): share the line wrapping icon --- packages/client/ui-primitives/src/JsonTree.tsx | 15 ++------------- packages/client/ui-primitives/src/icons/index.tsx | 8 ++++++++ .../ui-primitives/tests/icons.client.spec.tsx | 4 ++-- .../ui-trajectory/src/client/TrajectoryTable.tsx | 6 ++---- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 17e846d983..1e67b05e2b 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -6,7 +6,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode, } from 'react' -import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx' +import { IconCheckOutline16, IconCopyOutline16, IconWrapLinesOutline16 } from './icons/index.tsx' import { Menu } from './Menu.tsx' import type { MenuEntry } from './Menu.tsx' import css from './JsonTree.module.css' @@ -334,18 +334,7 @@ function JsonString({ stringWrapping.setDefault(next) }} > - + )} )} {onOpen === undefined && ( From e7fc4e8fdc34a375ff5a4883bfa98766bb4fc855 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 19:52:42 +0800 Subject: [PATCH 22/38] fix(client): isolate JSON copy feedback and guard string measurement --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + .../client/ui-primitives/src/JsonTree.tsx | 227 +++++++++++------- .../tests/json-tree.client.spec.tsx | 118 ++++++++- 5 files changed, 254 insertions(+), 99 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 7646128306..294912ed9e 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: d783a2d4eaaac887c5357fe30efc5e47249066be -README.zh.md: 33dc17db52e7263aa6aab81549a3b95f7dbfc59d +README.md: 888662a9e1150b3cf410f2f8202d0f27cba0e961 +README.zh.md: c1d5a4c45ff364925bb66506c0ea1bf8dae0df5d diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index d783a2d4ea..888662a9e1 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -74,6 +74,8 @@ The catalog above lists what each export is for; this section covers the behavio `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. +`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree. + ### Localizing copy The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 33dc17db52..c1d5a4c45f 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -74,6 +74,8 @@ kind: "package-library" `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 +`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。 + ### 本地化文案 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 1e67b05e2b..7a3f2e8b7f 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react' import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, @@ -65,6 +65,105 @@ interface RowTarget { value: unknown } +type CopyMode = 'json' | 'path' | 'prettyJson' | 'value' + +interface CopySnapshot { + id: string + target: RowTarget + state: 'idle' | 'copied' | 'failed' + menuOpen: boolean +} + +/** Notify only the old and new row actions; JSON values do not subscribe to hover state. */ +function createCopyStore() { + let current: CopySnapshot | undefined + const listeners = new Map void>>() + return { + get: () => current, + set(next: CopySnapshot | undefined) { + const previous = current?.id + current = next + for (const id of new Set([previous, next?.id])) { + if (id === undefined) continue + for (const listener of listeners.get(id) ?? []) listener() + } + }, + subscribe(id: string, listener: () => void) { + let row = listeners.get(id) + if (row === undefined) listeners.set(id, row = new Set()) + row.add(listener) + return () => { + row.delete(listener) + if (row.size === 0) listeners.delete(id) + } + }, + } +} + +function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }: { + store: ReturnType + target: RowTarget + persistent: boolean + labels: JsonTreeLabels + onCopy: (target: RowTarget, mode: CopyMode) => Promise + onClose: () => void +}) { + const id = pathId(target.path) + const subscribe = useCallback((listener: () => void) => store.subscribe(id, listener), [id, store]) + const getSnapshot = () => { + const current = store.get() + return current?.id === id ? current : undefined + } + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const buttonRef = useRef(null) + const state = snapshot?.state ?? 'idle' + const object = typeof target.value === 'object' && target.value !== null + const copyTitle = state === 'copied' + ? labels.copied + : state === 'failed' + ? labels.copyFailed + : object ? labels.copyPrettyJson : labels.copyValue + return ( + + {(persistent || snapshot !== undefined) && ( + void onCopy(target, object ? 'prettyJson' : 'value')} + onContextMenu={(event) => { + event.preventDefault() + event.stopPropagation() + store.set({ id, target, state, menuOpen: true }) + }} + > + {state === 'copied' + ? + : } + + )} + items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} + onSelect={(mode) => { + void onCopy(target, mode as CopyMode) + }} + onClose={onClose} + getAnchorRect={() => (buttonRef.current as HTMLButtonElement).getBoundingClientRect()} + /> + )} + + ) +} + function isExpandableValue(value: unknown): value is object | unknown[] { return typeof value === 'object' && value !== null && !(value instanceof Date) } @@ -273,6 +372,8 @@ function JsonString({ useLayoutEffect(() => { if (!expanded) return const raw = rawRef.current as HTMLPreElement + // Keep raw text within the window and clipping ancestors outside the tree. + // Capture scrolling because an ancestor can move the string without resizing it. const clips: HTMLElement[] = [] const tree = raw.closest(`.${css.root}`) as HTMLElement for (let parent = tree.parentElement; parent !== null; parent = parent.parentElement) { @@ -292,13 +393,13 @@ function JsonString({ raw.style.maxHeight = `${Math.max(16, available - 4)}px` } measure() - const observer = new ResizeObserver(measure) - observer.observe(raw) - for (const clip of clips) observer.observe(clip) + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure) + observer?.observe(raw) + for (const clip of clips) observer?.observe(clip) window.addEventListener('resize', measure) window.addEventListener('scroll', measure, true) return () => { - observer.disconnect() + observer?.disconnect() window.removeEventListener('resize', measure) window.removeEventListener('scroll', measure, true) } @@ -319,6 +420,7 @@ function JsonString({ > {value} + {!lastElement && ,}
{stringWrapping !== undefined && ( - )} - items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} - onSelect={(id) => { - void copy(target, id as 'json' | 'path' | 'prettyJson' | 'value') - copyMenuOpenRef.current = false - setCopyMenuOpen(false) - }} - onClose={clearCopyTarget} - getAnchorRect={() => ( - copyButtonRef.current as HTMLButtonElement - ).getBoundingClientRect()} - /> - )} - - ) - } : undefined + const renderCopy = copyable ? (target: RowTarget, persistent = false) => ( + + ) : undefined return (
{ - if (!copyMenuOpenRef.current) clearCopyTarget() + if (!copyStore.get()?.menuOpen) clearCopyTarget() }} > {expandTopLevel diff --git a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx index 2279b5cb6e..7a7c335ac0 100644 --- a/packages/client/ui-primitives/tests/json-tree.client.spec.tsx +++ b/packages/client/ui-primitives/tests/json-tree.client.spec.tsx @@ -12,12 +12,12 @@ function JsonTree(props: Omit, 'label' return } -let writeText: ReturnType +let writeText: ReturnType> let originalClipboard: PropertyDescriptor | undefined beforeEach(() => { originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') - writeText = vi.fn().mockResolvedValue(undefined) + writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, @@ -33,15 +33,115 @@ afterEach(() => { else Object.defineProperty(navigator, 'clipboard', originalClipboard) }) +function stubStringLayout(scrollHeight = 200): void { + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(scrollHeight) + const computedStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => { + const style = computedStyle(element) + style.lineHeight = '16px' + if (style.paddingBottom === '') style.paddingBottom = '0px' + return style + }) +} + describe('JsonTree', () => { - it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => { - vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(200) - const computedStyle = window.getComputedStyle.bind(window) - vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => { - const style = computedStyle(element) - style.lineHeight = '16px' - return style + it('ignores clipboard settlement after the row changes or the tree unmounts', async () => { + vi.useFakeTimers() + const pending: (() => void)[] = [] + writeText.mockImplementation(() => new Promise((resolve) => { pending.push(resolve) })) + const view = render() + const rows = screen.getAllByRole('treeitem') + fireEvent.mouseOver(rows[0] as HTMLElement) + fireEvent.click(screen.getByRole('button', { name: 'Copy value' })) + fireEvent.mouseOver(rows[1] as HTMLElement) + await act(async () => { pending[0]!() }) + expect(screen.queryByRole('button', { name: 'Copied' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Copy value' })) + view.unmount() + const timerCount = vi.getTimerCount() + await act(async () => { pending[1]!() }) + expect(vi.getTimerCount()).toBe(timerCount) + }) + + it('updates copy actions without rereading JSON properties on hover', () => { + const readValue = vi.fn(() => 'payload '.repeat(100)) + const data = Object.fromEntries(Array.from({ length: 500 }, (_, index) => [ + `field${index}`, + { get value() { return readValue() } }, + ])) + render() + const rows = within(screen.getByRole('tree')).getAllByRole('treeitem') + readValue.mockClear() + + fireEvent.mouseOver(rows[0] as HTMLElement) + expect(within(rows[0] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy() + fireEvent.mouseOver(rows[1] as HTMLElement) + expect(within(rows[0] as HTMLElement).queryByRole('button', { name: 'Copy pretty JSON' })).toBeNull() + expect(within(rows[1] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy() + expect(readValue).not.toHaveBeenCalled() + }) + + it('expands raw strings without ResizeObserver and keeps the visible viewport limit', () => { + stubStringLayout() + vi.stubGlobal('ResizeObserver', undefined) + let rawTop = 150 + const view = render( +
+ +
, + ) + const clip = view.container.firstElementChild as HTMLElement + vi.spyOn(clip, 'clientTop', 'get').mockReturnValue(2) + vi.spyOn(clip, 'clientHeight', 'get').mockReturnValue(140) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return new DOMRect(0, this === clip ? 100 : rawTop, 200, 140) }) + const rows = within(screen.getByRole('tree')).getAllByRole('treeitem') + fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Expand JSON node' })) + const raw = rows[0]?.querySelector('pre') as HTMLPreElement + expect(raw.textContent).toBe('raw\ntext') + expect(raw.style.maxHeight).toBe('78px') + expect(raw.nextElementSibling?.textContent).toBe(',') + + rawTop = 80 + fireEvent.scroll(clip) + expect(raw.style.maxHeight).toBe('126px') + rawTop = 300 + fireEvent.resize(window) + expect(raw.style.maxHeight).toBe('16px') + fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Collapse JSON node' })) + expect(raw.isConnected).toBe(false) + rawTop = 100 + fireEvent.scroll(clip) + expect(raw.style.maxHeight).toBe('16px') + + fireEvent.click(within(rows[1] as HTMLElement).getByRole('button', { name: 'Expand JSON node' })) + expect(rows[1]?.querySelector('pre')?.nextElementSibling?.textContent).not.toBe(',') + }) + + it('shows the string expander only beyond the configured collapsed line count', () => { + stubStringLayout(48) + let resize: (() => void) | undefined + const disconnect = vi.fn() + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe() {} + disconnect = disconnect + }) + const data = { text: 'three lines of text' } + const view = render() + expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull() + view.rerender() + expect(screen.getByRole('button', { name: 'Expand JSON node' })).toBeTruthy() + vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(16) + act(() => { resize?.() }) + expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull() + view.unmount() + expect(disconnect).toHaveBeenCalledTimes(2) + }) + + it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => { + stubStringLayout() vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} From 2bc1a7e347174c2bf14bc8d0e661f150ff101a3e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 19:52:42 +0800 Subject: [PATCH 23/38] test(trajectory): pin thinking defaults when switching records --- .../ui-trajectory/tests/table.client.spec.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 9723935bd4..5a882141bb 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -273,6 +273,26 @@ describe('TrajectoryTable', () => { expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true') }) + it('opens thinking on another record after collapsing the selected record', () => { + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [1, 2].map(index => ({ + index, kind: 'message', text: `Answer ${index}`, outputDetail: `Answer ${index}`, + thinkingDetail: `Reasoning ${index}`, timeSeconds: 1, + })), + }], + }] + render() + fireEvent.click(screen.getByRole('row', { name: /ASSISTANT, Answer 1/ })) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('false') + fireEvent.click(screen.getByRole('row', { name: /ASSISTANT, Answer 2/ })) + expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('Reasoning 2')).toBeTruthy() + }) + it('keeps raw HTML tags in a Markdown-derived context preview', () => { const html = [ '', From b6858789f0513079fcd16fbbe4bfe6348e489076 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 19:52:42 +0800 Subject: [PATCH 24/38] fix(trajectory): identify PTC calls from recorded tool metadata --- ...9-ptc-trajectory-code-inspection.i18n.yaml | 4 +-- ...26-09-09-ptc-trajectory-code-inspection.md | 4 +-- ...09-09-ptc-trajectory-code-inspection.zh.md | 4 +-- .../client/ui-trajectory/README.i18n.yaml | 4 +-- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../src/client/TrajectoryCell.tsx | 1 + .../src/client/TrajectoryTable.tsx | 30 ++++++++++--------- .../ui-trajectory/src/client/code-program.ts | 7 ++++- .../client/ui-trajectory/src/client/layout.ts | 3 +- .../src/client/trajectory-record.ts | 2 ++ .../tests/code-inspector.client.spec.tsx | 2 +- .../tests/code-program.client.spec.ts | 14 +++++++-- 13 files changed, 50 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml index d273d9ed77..c9f4dad8fa 100644 --- a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md -2026-09-09-ptc-trajectory-code-inspection.md: 55cbbae97371b69b7574ebb0674c2178b586b83a -2026-09-09-ptc-trajectory-code-inspection.zh.md: 94b6993a4e4e2d1f3e93256f79a68121f910169d +2026-09-09-ptc-trajectory-code-inspection.md: 477d199f519b5d515e5d58430bd902d9d209e46b +2026-09-09-ptc-trajectory-code-inspection.zh.md: f305569176214c63ac549b0ec5103289e3e57a88 diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md index 55cbbae973..477d199f51 100644 --- a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md @@ -10,7 +10,7 @@ PTC programs arrive as JSON string arguments. Escaping makes long programs diffi ## Decision -[Trajectory](../../../../packages/client/ui-trajectory/README.md) derives a code inspector from recorded `run_code` arguments. It preserves source bytes for copying and exposes the original JSON through a separate toggle. Syntax highlighting requires an unambiguous TypeScript or Python hint in that call's recorded parameter description; unknown or conflicting hints leave plain text. Unsupported arguments retain the generic inspector. +[Trajectory](../../../../packages/client/ui-trajectory/README.md) identifies calls by their recorded tool name and derives a code inspector from validated `run_code` arguments. The validated program carries the original JSON text; copying preserves source and argument bytes and exposes the original JSON through a separate toggle. Syntax highlighting requires an unambiguous TypeScript or Python hint in that call's recorded parameter description; unknown or conflicting hints leave plain text. Unsupported arguments retain the generic inspector. The result view preserves recorded text and uses a tree only for complete JSON objects or arrays. Errors retain captured output. Code views sample the shared wrapping preference when opened and keep their own choice while mounted. @@ -26,4 +26,4 @@ The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlemen ## Consequences -Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Existing component tests cover fallback, exact copying, output states, and independent wrapping; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip. +Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Component tests cover recorded-name recognition, schema fallback, exact source and argument copying, output states, and independent wrapping. JSON-tree tests cover clipping geometry, missing `ResizeObserver`, clipboard settlement after hover changes or unmount, and value-read counts during hover. Thinking tests cover body arrival, manual disclosure, and switching records; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip. diff --git a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md index 94b6993a4e..f305569176 100644 --- a/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md @@ -10,7 +10,7 @@ PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树 ## 决策 -[Trajectory](../../../../packages/client/ui-trajectory/README.zh.md) 从已记录的 `run_code` 参数派生代码检查器。复制时保留源码字节,并通过独立切换按钮展示原始 JSON。语法高亮要求该调用记录的参数说明包含明确且无冲突的 TypeScript 或 Python 提示;未知或冲突的提示使用纯文本。不支持的参数保留通用检查器。 +[Trajectory](../../../../packages/client/ui-trajectory/README.zh.md) 按已记录的工具名识别调用,并从已验证的 `run_code` 参数派生代码检查器。验证后的程序携带原始 JSON 文本;复制时保留源码与参数字节,并通过独立切换按钮展示原始 JSON。语法高亮要求该调用记录的参数说明包含明确且无冲突的 TypeScript 或 Python 提示;未知或冲突的提示使用纯文本。不支持的参数保留通用检查器。 结果视图保留记录的文本,只对完整 JSON 对象或数组使用树形展示。错误保留已捕获的输出。代码视图在打开时读取共享换行偏好,并在挂载期间保留自身的选择。 @@ -26,4 +26,4 @@ PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树 ## 后果 -读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。现有组件测试覆盖回退、原样复制、输出状态与独立换行;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。 +读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。组件测试覆盖记录工具名识别、Schema 回退、源码与参数原样复制、输出状态及独立换行。JSON 树测试覆盖裁剪几何、缺少 `ResizeObserver`、悬停切换或卸载后剪贴板写入落定,以及悬停期间读取值的次数。思考测试覆盖正文到达、手动展开折叠及记录切换;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。 diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 02a420834f..f969a1bf95 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 80d8898e0fabd4671581a84198250fe408d51009 -README.zh.md: 5c85faaadb56be6dc3cc85753494c7fd88333c92 +README.md: 6530eb83563620fc26761c421204427c9698732b +README.zh.md: 09ffd59282e407f61126b6e5064cf75740bb52c1 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 80d8898e0f..6530eb8356 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -29,7 +29,7 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit ### Inspecting records -For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints. +Calls are identified by their recorded tool name. For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Copying the original arguments retains their recorded JSON whitespace. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints. Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 5c85faaadb..09ffd59282 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -29,7 +29,7 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 ### 检查记录 -对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 +调用按已记录的工具名识别。对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。复制原始参数时保留记录中 JSON 的空白。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留 snapshot。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx index a25b09833e..bb0b247979 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -57,6 +57,7 @@ export function TrajectoryCell({ assistantMetrics: _assistantMetrics, result: _result, callId: _callId, + toolName: _toolName, isError: _isError, timeSeconds, startedAt: _startedAt, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 9d4479bbab..6babf88c4e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -36,7 +36,7 @@ import type { TrajectoryTurnModel } from './layout.ts' import { trajectoryPreviewText } from './trajectory-preview.ts' import type { TrajectoryKey, TrajectoryTranslate } from './locales.ts' import { COMPACTION_INTERRUPTED_ERROR } from './copy-codes.ts' -import { codeProgram, type CodeProgram } from './code-program.ts' +import { codeProgram, PTC_TOOL_NAME, type CodeProgram } from './code-program.ts' import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 @@ -190,6 +190,7 @@ interface ParentRecords { } interface ToolCallTextParts { + program: boolean name: string args?: string } @@ -1011,7 +1012,7 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { function recordDisplayText(cell: TrajectoryCellProps, t: TrajectoryTranslate): string { if (isToolCallOnly(cell, t)) return '' const program = codeProgram(cell) - if (program !== undefined) return `${cell.text.split(' · ', 1)[0]} · ${program.description.replace(/\s+/g, ' ')}` + if (program !== undefined) return `${PTC_TOOL_NAME} · ${program.description.replace(/\s+/g, ' ')}` if (cell.previewMarkdown !== undefined) { const preview = trajectoryPreviewText(cell.previewMarkdown) if (cell.text === '') return preview @@ -1033,13 +1034,15 @@ function recordResultText(cell: TrajectoryCellProps): string | undefined { } function toolCallTextParts( - kind: TrajectoryCellKind, + cell: TrajectoryCellProps, text: string, ): ToolCallTextParts | undefined { - if (kind !== 'tool' && kind !== 'subtool') return undefined + if (cell.kind !== 'tool' && cell.kind !== 'subtool') return undefined + const program = cell.toolName === PTC_TOOL_NAME const separator = text.indexOf(' · ') - if (separator === -1) return { name: text } + if (separator === -1) return { name: text, program } return { + program, name: text.slice(0, separator), args: text.slice(separator + 3), } @@ -1072,7 +1075,7 @@ function RecordPresentation({ const displayText = useMemo( () => recordDisplayText(cell, t), [ - cell.kind, cell.text, cell.previewMarkdown, + cell.kind, cell.text, cell.toolName, cell.previewMarkdown, cell.inputDetail, cell.outputDetail, cell.thinkingDetail, t, ], ) @@ -1081,7 +1084,7 @@ function RecordPresentation({ [cell.result, cell.resultPreviewMarkdown], ) const toolCallOnly = isToolCallOnly(cell, t) - const toolCallText = toolCallTextParts(cell.kind, displayText) + const toolCallText = toolCallTextParts(cell, displayText) const listDisplayText = toolCallOnly ? t('record.toolCallOnly') : toolCallText === undefined @@ -1111,11 +1114,11 @@ function RecordListText({ return ( <> - {toolCallText.name === 'run_code' && } + {toolCallText.program && } {toolCallText.name || '—'} {toolCallText.args !== undefined && ( - + {toolCallText.args} )} @@ -1840,9 +1843,8 @@ function InspectorCopyButton({ text, label, t }: { ) } -function ProgramInput({ program, rawInput, initialWrapped, stringWrapping, onOpen, t }: { +function ProgramInput({ program, initialWrapped, stringWrapping, onOpen, t }: { program: CodeProgram - rawInput: string initialWrapped: boolean stringWrapping: JsonTreeProps['stringWrapping'] onOpen?: () => void @@ -1891,7 +1893,7 @@ function ProgramInput({ program, rawInput, initialWrapped, stringWrapping, onOpe )} @@ -3350,7 +3352,7 @@ export function TrajectoryTable({ ? ( <> { activateTab('input') }} t={t} /> { activateTab('output') }} t={t} /> @@ -3437,7 +3439,7 @@ export function TrajectoryTable({ selectedProgram === undefined ? : )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( diff --git a/packages/client/ui-trajectory/src/client/code-program.ts b/packages/client/ui-trajectory/src/client/code-program.ts index 98762cdcbd..e444029a20 100644 --- a/packages/client/ui-trajectory/src/client/code-program.ts +++ b/packages/client/ui-trajectory/src/client/code-program.ts @@ -1,8 +1,12 @@ /** Read replayable PTC source and language hints from recorded tool arguments and schemas. */ import type { TrajectoryCellProps } from './trajectory-record.ts' +/** Recorded name of the programmatic tool-calling entry point. */ +export const PTC_TOOL_NAME = 'run_code' + /** Validated source and original arguments for one recorded run_code call. */ export interface CodeProgram { + rawInput: string source: string description: string arguments: Record @@ -42,11 +46,12 @@ function recordedLanguage(schemaRaw: string | undefined): CodeProgram['language' */ export function codeProgram(cell: TrajectoryCellProps): CodeProgram | undefined { if (cell.kind !== 'tool' && cell.kind !== 'subtool') return undefined - if (cell.text.split(' · ', 1)[0] !== 'run_code') return undefined + if (cell.toolName !== PTC_TOOL_NAME || cell.inputDetail === undefined) return undefined const args = parseRecord(cell.inputDetail) if (typeof args?.code !== 'string') return undefined if (args.description !== undefined && typeof args.description !== 'string') return undefined return { + rawInput: cell.inputDetail, source: args.code, description: typeof args.description === 'string' && args.description.trim() !== '' ? args.description diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index fd361899d0..7691b7f74a 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -1062,8 +1062,9 @@ function expandSubCalls( function summarizeCall( name: string, argsRaw: string, -): Pick { +): Pick { return { + toolName: name, text: name, ...(argsRaw === '' ? {} : { previewMarkdown: argsRaw }), } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 2bcb268f22..017143c487 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -80,6 +80,8 @@ export interface TrajectoryCellProps extends HTMLAttributes { resultPreviewMarkdown?: string /** Tool call id used to link message source blocks to tool records. */ callId?: string + /** Recorded tool name; independent of the display summary. */ + toolName?: string /** Tool-only result failure state. */ isError?: boolean /** Own duration in seconds, or `null` when no duration is known. */ diff --git a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx index 748463b6ea..fab29e0637 100644 --- a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx @@ -15,7 +15,7 @@ afterEach(() => { const SOURCE = 'const value = await tools.bash({ command: "pwd" })\nreturn value\n' const ARGS = JSON.stringify({ code: SOURCE, description: 'Read the working directory' }) const CELL: TrajectoryCellProps = { - index: 1, kind: 'tool', text: `run_code · ${ARGS}`, callId: 'code-1', + index: 1, kind: 'tool', toolName: 'run_code', text: `run_code · ${ARGS}`, callId: 'code-1', inputDetail: ARGS, outputDetail: 'printed line\n42', result: 'printed line', timeSeconds: 0.5, } diff --git a/packages/client/ui-trajectory/tests/code-program.client.spec.ts b/packages/client/ui-trajectory/tests/code-program.client.spec.ts index c0c96ca044..a9bd21c807 100644 --- a/packages/client/ui-trajectory/tests/code-program.client.spec.ts +++ b/packages/client/ui-trajectory/tests/code-program.client.spec.ts @@ -4,13 +4,14 @@ import { codeProgram } from '../src/client/code-program.ts' import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts' const CELL: TrajectoryCellProps = { - index: 1, kind: 'tool', text: 'run_code · {}', timeSeconds: 1, + index: 1, kind: 'tool', toolName: 'run_code', text: 'run_code · {}', timeSeconds: 1, inputDetail: JSON.stringify({ code: 'return 42\n', description: 'Compute the answer' }), } describe('recorded code programs', () => { it('keeps the program and arguments verbatim', () => { expect(codeProgram(CELL)).toEqual({ + rawInput: CELL.inputDetail, source: 'return 42\n', description: 'Compute the answer', arguments: { code: 'return 42\n', description: 'Compute the answer' }, language: undefined, }) @@ -52,9 +53,18 @@ describe('recorded code programs', () => { }, ) + it('uses recorded tool metadata independently of display text', () => { + expect(codeProgram({ ...CELL, text: 'Execute program' })?.source).toBe('return 42\n') + const withoutName = { ...CELL } + delete withoutName.toolName + expect(codeProgram(withoutName)).toBeUndefined() + const rawInput = ' { "code": "return 42" }\n' + expect(codeProgram({ ...CELL, inputDetail: rawInput })?.rawInput).toBe(rawInput) + }) + it('does not specialize other tools or non-tool records', () => { expect(codeProgram({ ...CELL, kind: 'message' })).toBeUndefined() - expect(codeProgram({ ...CELL, text: 'run_code_extra · {}' })).toBeUndefined() + expect(codeProgram({ ...CELL, toolName: 'run_code_extra' })).toBeUndefined() expect(codeProgram({ ...CELL, kind: 'subtool' })?.source).toBe('return 42\n') }) }) From 29c09d320fd9d2325c034ed1bcc15bc01354486c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 9 Sep 2026 19:52:42 +0800 Subject: [PATCH 25/38] refactor(client): remove unused JSON disclosure locale keys --- packages/client/locale/src/locales/en.ts | 2 -- packages/client/locale/src/locales/zh.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index 70ce31598c..d16b4913ae 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -34,8 +34,6 @@ export const en = { 'unknown': 'Unknown', 'none': 'None', 'truncated': 'Truncated', - 'json.collapseNode': 'Collapse JSON node', - 'json.expandNode': 'Expand JSON node', 'json.label': 'JSON', 'markdown.footnotes': 'Footnotes', 'markdown.truncatedCharacters': '… truncated at {total} characters', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 30cee308d5..2e932cef41 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -32,8 +32,6 @@ export const zh = { 'unknown': '未知', 'none': '无', 'truncated': '已截断', - 'json.collapseNode': '收起 JSON 节点', - 'json.expandNode': '展开 JSON 节点', 'json.label': 'JSON', 'markdown.footnotes': '脚注', 'markdown.truncatedCharacters': '… 已截断,共 {total} 字符', From d28625c6d5397cac5046d08eb3440348b7cd6c56 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 9 Sep 2026 19:52:55 +0800 Subject: [PATCH 26/38] fix(web): restore terminal snapshots across host and sidebar lifecycles --- .../2026-09-09-web-sidebar-terminal.i18n.yaml | 4 +- .../2026-09-09-web-sidebar-terminal.md | 4 +- .../2026-09-09-web-sidebar-terminal.zh.md | 4 +- apps/web/package.json | 3 +- .../tests/details-session-lifecycle.e2e.ts | 3 + apps/web/tests/document-preview.e2e.ts | 1 + apps/web/tests/sidebar-terminal.e2e.ts | 83 +++++++++++-------- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 8 +- docs/subsystems/workspace.zh.md | 8 +- .../api/terminal-controller/README.i18n.yaml | 4 +- packages/api/terminal-controller/README.md | 2 +- packages/api/terminal-controller/README.zh.md | 2 +- packages/api/terminal-controller/src/index.ts | 8 +- .../tests/controller.spec.ts | 42 +++++----- .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 6 +- packages/client/ui-sidebar-right/README.zh.md | 4 +- packages/client/ui-sidebar-right/package.json | 3 +- .../ui-sidebar-right/src/client/service.ts | 3 +- .../src/client/tab-registry.ts | 4 +- .../src/client/tabs/guide/GuideBody.tsx | 5 +- .../tests/guide-body.client.spec.tsx | 5 -- .../tests/service.client.spec.ts | 19 +++++ .../ui-sidebar-terminal/src/client/index.ts | 6 +- .../tests/apply.client.spec.ts | 7 +- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- pnpm-lock.yaml | 6 ++ 28 files changed, 150 insertions(+), 108 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index d976a5b494..4151a21804 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: 4339e6aa0bbb28383a9f2b42336965d44b1a0e07 -2026-09-09-web-sidebar-terminal.zh.md: aec1286b0d6e594813735acfd06dbaf34f261972 +2026-09-09-web-sidebar-terminal.md: d95968712e7f99b8e83d020273bb02818b4b2824 +2026-09-09-web-sidebar-terminal.zh.md: 2b8ed2fa1317dce510e01d840acd3b99f40eaccf diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index 4339e6aa0b..d95968712e 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -10,11 +10,11 @@ Web users need an interactive shell beside a Session to inspect the workspace an ## Decision -`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal starts the execution environment's default shell immediately. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs only during creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. +`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal starts the execution environment's default shell immediately. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs only during creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. -Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry. +Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Listing takes the Session ID directly because history can outlive its Agent and terminal owner; an offline Session has no retained terminals to restore. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry. The caller retains a terminal ID in memory before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index aec1286b0d..2b8ed2fa13 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -10,11 +10,11 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 ## 决定 -`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端立即启动执行环境的默认 shell。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 只在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 +`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端立即启动执行环境的默认 shell。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 只在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 -侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。 +侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。列表直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。 调用者在创建之前把终端 ID 保留在内存中。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。 diff --git a/apps/web/package.json b/apps/web/package.json index 6c6dbc31aa..0a59473f65 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,6 +55,7 @@ "vite": "^6.0.0", "vitest": "^4.1.8", "ws": "8.21.0", - "@deepseek-ai/dsh-launch-environment": "workspace:^" + "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^" } } diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 14245fafa0..a02bafac00 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -243,6 +243,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await select(original, 'LIGHTHOUSE') await open() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() // The content-box panel adds its one rendered border pixel outside the // CSS width assigned by the grid solver. await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 }) @@ -256,6 +257,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => split.isDisabled()).toBe(false) await split.click() await expect.poll(() => panes.count()).toBe(2) + await panes.last().locator('[data-sidebar-right-guide-entry="files"]').click() await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click() await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1) const retainedA = await paneSnapshot(page) @@ -278,6 +280,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => detailsTrack(page)).toBe(0) await open() expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push') + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 }) const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ }) await workspaceDirectory.waitFor({ timeout: 15_000 }) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 58b9913ce6..a15ec15e31 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -123,6 +123,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const column = page.locator('[data-rightbar-col]') await page.locator('[data-sidebar-right-expand]').click() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) await column.locator('[data-files-reload]').click() const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index ea79df7ec2..73030f48be 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -1,11 +1,12 @@ /** Shipped sidebar terminal over the real Loader, Remote mux, Chromium and local PTY. */ import { mkdir } from 'node:fs/promises' -import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-api-terminal-controller' +import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import { createProcessInspector, type ProcessIdentity } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -27,29 +28,23 @@ async function command(page: Page, text: string): Promise { await page.keyboard.press('Enter') } -function alive(pid: number): boolean { - try { - process.kill(pid, 0) - // A container's init may retain a reparented zombie; it cannot run after terminal cleanup. - if (process.platform === 'linux') { - const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') - const state = stat.slice(stat.lastIndexOf(')') + 2, stat.lastIndexOf(')') + 3) - return state !== 'Z' && state !== 'X' - } - return true - } catch (error) { - if (['ESRCH', 'ENOENT'].includes((error as NodeJS.ErrnoException).code ?? '')) return false - throw error - } -} - describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType - beforeAll(async () => { + let handles: SubprocessTerminalHandle[] + const inspector = createProcessInspector() + const alive = (identity: ProcessIdentity) => inspector.isAlive(identity) + const processIdentity = (index: number): ProcessIdentity => { + const pid = handles[index]!.pid + const identity = inspector.snapshot().tree(pid).find(member => member.pid === pid) + if (identity === undefined) throw new Error(`Terminal process ${pid} is missing`) + return identity + } + + beforeEach(async () => { scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) }) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -59,6 +54,15 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await connectFreshWorkspace(page, scaffold.workspaceCwd) const agent = scaffold.ctx.agents.list()[0] if (agent === undefined) throw new Error('Workspace did not create a Session') + handles = [] + const subprocess = agent.ctx.get('subprocess') + if (subprocess === undefined) throw new Error('Session subprocess provider is missing') + const spawn = subprocess.spawnTerminal.bind(subprocess) + vi.spyOn(subprocess, 'spawnTerminal').mockImplementation(async (spec) => { + const handle = await spawn(spec) + handles.push(handle) + return handle + }) agent.session.append('turn/start', { turn: 1 }) agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Open a terminal.' }], source: { kind: 'user' } }), { surfaceOp: 'append' }) agent.session.append('step/start', { turn: 1, step: 1 }) @@ -70,7 +74,11 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await mkdir(shots, { recursive: true }) }, 180_000) - afterAll(async () => { await browser?.close(); await scaffold?.close() }) + afterEach(async () => { + try { await browser?.close() } finally { + try { await scaffold?.close() } finally { vi.restoreAllMocks() } + } + }) it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal')) @@ -84,7 +92,8 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await command(page, "printf 'DSH_PID:%s\\n' \"$$\"") await expect.poll(async () => await screen.innerText()).toMatch(/DSH_PID:\d+/u) const pid = Number((await screen.innerText()).match(/DSH_PID:(\d+)/u)?.[1]) - expect(alive(pid)).toBe(true) + const firstProcess = processIdentity(0) + expect(alive(firstProcess)).toBe(true) await command(page, 'dsh_terminal_completion_probe(){ printf "completed_from_shell\\n"; }') await page.keyboard.press('Control+l') await page.keyboard.insertText('dsh_terminal_completion_pro') @@ -101,14 +110,16 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await command(page, "printf 'SECOND_PID:%s\\n' \"$$\"") await expect.poll(async () => await screen.innerText()).toMatch(/SECOND_PID:\d+/u) const secondPid = Number((await screen.innerText()).match(/SECOND_PID:(\d+)/u)?.[1]) - expect(secondPid).not.toBe(pid) + // Shell PIDs belong to the sandbox namespace; process liveness uses Host identities. + const secondProcess = processIdentity(1) + expect(secondProcess.pid).not.toBe(firstProcess.pid) await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).click() await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') - expect(alive(secondPid)).toBe(true) + expect(alive(secondProcess)).toBe(true) await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() - expect(alive(pid)).toBe(true) + expect(alive(firstProcess)).toBe(true) await page.locator('[data-sidebar-right-expand]').click() - const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!) + const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id) const dockedCols = terminals()[0]!.cols await page.getByRole('button', { name: 'Fullscreen', exact: true }).click() await expect.poll(() => terminals()[0]!.cols).toBeGreaterThan(dockedCols) @@ -117,36 +128,38 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true }) await page.reload({ waitUntil: 'load' }) await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 }) - await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Start', 'Development', 'bash']) + await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Development', 'bash']) expect(terminals()).toHaveLength(2) - expect(alive(pid)).toBe(true) - expect(alive(secondPid)).toBe(true) + expect(alive(firstProcess)).toBe(true) + expect(alive(secondProcess)).toBe(true) await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`) const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }) await secondTab.hover() await secondTab.locator('[data-dockkit-tab-close]').click() await expect.poll(async () => await secondTab.count()).toBe(0) - await expect.poll(() => alive(secondPid), { timeout: 10_000 }).toBe(false) + await expect.poll(() => alive(secondProcess), { timeout: 10_000 }).toBe(false) await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color') await command(page, "printf 'RECOVERED_PID:%s\\n' \"$$\"") await expect.poll(async () => await screen.innerText()).toContain(`RECOVERED_PID:${pid}`) await page.screenshot({ path: `${shots}/recovered.png`, fullPage: true }) + const previousMembers = new Set(inspector.snapshot().tree(firstProcess.pid).map(member => member.pid)) await command(page, "sleep 120 & printf 'CHILD_PID:%s\\n' $!") await expect.poll(async () => await screen.innerText()).toMatch(/CHILD_PID:\d+/u) - const childPid = Number((await screen.innerText()).match(/CHILD_PID:(\d+)/u)?.[1]) - expect(alive(childPid)).toBe(true) + const descendants = inspector.snapshot().tree(firstProcess.pid).filter(member => member.pid !== firstProcess.pid) + expect(descendants.some(member => !previousMembers.has(member.pid))).toBe(true) + expect(descendants.every(alive)).toBe(true) const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }) await tab.hover() await tab.locator('[data-dockkit-tab-close]').click() - await expect.poll(() => alive(pid), { timeout: 10_000 }).toBe(false) - await expect.poll(() => alive(childPid), { timeout: 10_000 }).toBe(false) - await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!).length).toBe(0) + await expect.poll(() => alive(firstProcess), { timeout: 10_000 }).toBe(false) + await expect.poll(() => descendants.some(alive), { timeout: 10_000 }).toBe(false) + await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).length).toBe(0) expect(tripwire.pageErrors).toEqual([]) }) it('explains that exited terminals count toward the quota and permits creation after closing one', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota')) - const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!) + const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id) for (let count = 1; count <= 2; count++) { await openTerminal(page) await command(page, 'exit') diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index e50df6993c..7c7d1973a1 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 94031d6803f5de3a4ddb3e3654d40bebe74a92cc -workspace.zh.md: 2b131f9bfd51ecf63bd11b6e91ed181bc126ab94 +workspace.md: b0c194e7c0a8134a126bfd3a786615d67245e5d3 +workspace.zh.md: db4c1284a5481e48425abd004b4c7ea4db3ce742 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 94031d6803..b0c194e7c0 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -199,11 +199,11 @@ Typed Remote control of transient Session-owned terminal processes. @Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment /** - * List running and exited terminals available for reattachment. - * @param agent - Session owner supplied by the Gateway. + * List retained terminals without resolving or activating an Agent. + * @param sessionId - displayed Session identity, including offline history. * @returns terminals retained for this Host lifetime. */ -@Remote list(agent: Agent): WebTerminalInfo[] +@Remote list(sessionId: SessionId): WebTerminalInfo[] /** * Allocate an interactive shell once for a caller-generated identity. @@ -262,7 +262,7 @@ Typed Remote control of transient Session-owned terminal processes. @Remote async close(agent: Agent, id: WebTerminalId): Promise ``` -Types: [Agent](core.md) +Types: [Agent](core.md) · [SessionId](core.md) Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 2b131f9bfd..db4c1284a5 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -199,11 +199,11 @@ Typed Remote control of transient Session-owned terminal processes. @Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment /** - * List running and exited terminals available for reattachment. - * @param agent - Session owner supplied by the Gateway. + * List retained terminals without resolving or activating an Agent. + * @param sessionId - displayed Session identity, including offline history. * @returns terminals retained for this Host lifetime. */ -@Remote list(agent: Agent): WebTerminalInfo[] +@Remote list(sessionId: SessionId): WebTerminalInfo[] /** * Allocate an interactive shell once for a caller-generated identity. @@ -262,7 +262,7 @@ Typed Remote control of transient Session-owned terminal processes. @Remote async close(agent: Agent, id: WebTerminalId): Promise ``` -Types: [Agent](core.zh.md) +Types: [Agent](core.zh.md) · [SessionId](core.zh.md) Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts) diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index 4ede4369b5..b3c9756b81 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md -README.md: 63da44832aa6087f43b9d9ecdafc56010f17245e -README.zh.md: abcaa83d41ff525d9e7a60ae49c27636a5c8b411 +README.md: e6ffceff8cd3c16ad7a6d61697dec870bba0fe14 +README.zh.md: 609493cfc0fa127473538bc6ce79e6083f6d8703 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index 63da44832a..e6ffceff8c 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -25,7 +25,7 @@ Open the execution environment's default shell in a Session workspace from the W ## Use this package -The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. +The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. New terminals use the execution environment's declared default shell. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The provider verifies the executable during creation; resolution failure is reported without trying another shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal. diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index abcaa83d41..609493cfc0 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。 +Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 新终端使用执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。provider 在创建时验证可执行文件;解析失败会直接报告,不尝试其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。 diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts index d55771ae27..7b854476b7 100644 --- a/packages/api/terminal-controller/src/index.ts +++ b/packages/api/terminal-controller/src/index.ts @@ -118,13 +118,13 @@ export class TerminalController extends TypertRemoteService { } /** - * List running and exited terminals available for reattachment. - * @param agent - Session owner supplied by the Gateway. + * List retained terminals without resolving or activating an Agent. + * @param sessionId - displayed Session identity, including offline history. * @returns terminals retained for this Host lifetime. */ @Remote - list(agent: Agent): WebTerminalInfo[] { - const owner = this.owners.get(agent.id) + list(sessionId: SessionId): WebTerminalInfo[] { + const owner = this.owners.get(sessionId) if (owner === undefined) return [] return [...owner.terminals.values(), ...owner.allocations.values()].map(terminal => terminal.info) } diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index 7910182675..619750f9ff 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -82,7 +82,7 @@ describe('TerminalController', () => { await controller.create(agent, request, signal()) subprocess.resolveExecutable.mockRejectedValue(new SubprocessExecutableNotFoundError('default shell was removed')) expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace', maxInputBytes: 1000 }) - expect(controller.list(agent)).toMatchObject([{ id, state: 'running' }]) + expect(controller.list(agent.id)).toMatchObject([{ id, state: 'running' }]) const stream = controller.follow(agent, id, 'restored' as TerminalAttachmentId, signal())[Symbol.asyncIterator]() try { expect(await stream.next()).toMatchObject({ done: false, value: { type: 'snapshot', info: { id, state: 'running' } } }) @@ -107,7 +107,7 @@ describe('TerminalController', () => { expect(first.id).toBe(second.id) expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ terminalType: 'xterm-256color', cwd: '/workspace' })) - expect(controller.list(owner(ctx, 'other'))).toEqual([]) + expect(controller.list('other' as SessionId)).toEqual([]) expect(() => controller.follow(owner(ctx, 'other'), id, 'writer' as TerminalAttachmentId, signal())).toThrow('no longer exists') const abort = new AbortController() const stream = controller.follow(agent, id, 'writer' as TerminalAttachmentId, abort.signal)[Symbol.asyncIterator]() @@ -119,7 +119,7 @@ describe('TerminalController', () => { expect(handle.terminate).not.toHaveBeenCalled() await controller.close(agent, id) expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) await controller.close(agent, id) }) @@ -133,7 +133,7 @@ describe('TerminalController', () => { allocation.resolve(handle) await rejected await closing - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) expect(handle.terminate).toHaveBeenCalledOnce() }) @@ -166,7 +166,7 @@ describe('TerminalController', () => { await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') expect(handle.terminate).toHaveBeenCalledOnce() expect(subprocess.spawnTerminal).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) } finally { allocation.resolve(handle) } }) @@ -176,10 +176,10 @@ describe('TerminalController', () => { subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('request disconnected')); return handle }) vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('cleanup failed')) await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') - expect(controller.list(agent)).toMatchObject([{ id, state: 'failed' }]) + expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed' }]) await controller.close(agent, id) expect(handle.terminate).toHaveBeenCalledTimes(2) - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('retains a terminal when process cleanup fails so closing can be retried', async () => { @@ -187,10 +187,10 @@ describe('TerminalController', () => { await controller.create(agent, request, signal()) vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('process still alive')) await expect(controller.close(agent, id)).rejects.toThrow('still alive') - expect(controller.list(agent)).toHaveLength(1) + expect(controller.list(agent.id)).toHaveLength(1) await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session') await controller.close(agent, id) - expect(controller.list(agent)).toHaveLength(0) + expect(controller.list(agent.id)).toHaveLength(0) }) it('rejects invalid dimensions, unavailable shells and oversized input before executing them', async () => { @@ -223,11 +223,11 @@ describe('TerminalController', () => { subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort('lost request'); return handle }) handle.terminate.mockRejectedValueOnce(new Error('still alive')) await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') - expect(controller.list(agent)).toMatchObject([{ id, state: 'failed', error: 'lost request' }]) + expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed', error: 'lost request' }]) await expect(controller.create(agent, request, signal())).rejects.toThrow('Close the failed terminal allocation') await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } }) await controller.close(agent, id) - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('lets close reclaim an allocation whose request failed while close was waiting', async () => { @@ -243,7 +243,7 @@ describe('TerminalController', () => { await rejected await closing expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('routes input, resize and trimmed names through the active attachment', async () => { @@ -259,7 +259,7 @@ describe('TerminalController', () => { await controller.resize(agent, id, attachmentId, 200, 100) expect(handle.resize).toHaveBeenCalledWith(200, 100) controller.rename(agent, id, ' server logs ') - expect(controller.list(agent)).toMatchObject([{ title: 'server logs', cols: 200, rows: 100 }]) + expect(controller.list(agent.id)).toMatchObject([{ title: 'server logs', cols: 200, rows: 100 }]) } finally { await stream.return?.() } }) @@ -278,7 +278,7 @@ describe('TerminalController', () => { expect(() => { controller.rename(agent, id, ' ') }).toThrow('1–120') expect(() => { controller.rename(agent, id, 'x'.repeat(121)) }).toThrow('1–120') await controller.close(agent, id) - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('uses the Session sandbox policy to confine its selected shell', async () => { @@ -329,7 +329,7 @@ describe('TerminalController', () => { await controller.create(agent, request, signal()) await disposeEffect('terminal-controller.owner') expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('cleans up a Session when its real Agent plugin fiber is disposed', async () => { @@ -340,7 +340,7 @@ describe('TerminalController', () => { await controller.create(agent, request, signal()) await fiber.dispose() expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) it('waits for pending creation during Host disposal and rolls back its process before resolving', async () => { @@ -356,7 +356,7 @@ describe('TerminalController', () => { await rejected await disposing expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) await expect(controller.create(agent, request, signal())).rejects.toThrow('controller disposed') }) @@ -375,7 +375,7 @@ describe('TerminalController', () => { stopped.resolve(undefined) await Promise.all([ownerDisposal, hostDisposal]) expect(handle.terminate).toHaveBeenCalledOnce() - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) } finally { stopped.resolve(undefined) } }) @@ -387,9 +387,9 @@ describe('TerminalController', () => { await expect(controller.create(agent, request, signal())).rejects.toThrow('Session owner disposed') handle.terminate.mockRejectedValueOnce(new Error('still alive again')) await expect(disposeEffect('terminal-controller.processes')).rejects.toThrow('Browser terminal cleanup failed') - expect(controller.list(agent)).toHaveLength(1) + expect(controller.list(agent.id)).toHaveLength(1) await controller.close(agent, id) - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) expect(handle.terminate).toHaveBeenCalledTimes(3) }) @@ -401,7 +401,7 @@ describe('TerminalController', () => { await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed') await disposeEffect('terminal-controller.owner') expect(handle.terminate).toHaveBeenCalledTimes(2) - expect(controller.list(agent)).toEqual([]) + expect(controller.list(agent.id)).toEqual([]) }) }) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 3c693bf003..c057a3c9db 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md -README.md: 3ee8049eff334e7443e206b8e26c246ca815a19d -README.zh.md: 3c0986438caffbdde1221b7caaf89e228f09a210 +README.md: b7084379b5d6e4757fa741d54414216a63be7204 +README.zh.md: 096a134eecd40df354b3c77f7829a1bfa9d9f22c diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 3ee8049eff..b7084379b5 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -84,7 +84,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r ## `ctx.sidebarRight` -`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. +`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; ordinary page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. `close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. @@ -98,9 +98,11 @@ Tab owners register `registerCloseHandler(kind, handler)` through an effect. A h ## The guide +A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness. + Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule. -The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule replaces the guide with its page. An entry may set `revealIfOpened: false` to create another tab; omission keeps the normal reveal of an existing page. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. +The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule replaces the guide with its page. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. ## Copy diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 3c0986438c..096a134eec 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -98,9 +98,11 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私 ## 引导页 +终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。 + 默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面;没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。 -引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会用对应页面替换引导页。条目可设置 `revealIfOpened: false` 来新建另一个 tab;省略时保留聚焦已有页面的默认行为。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 +引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会用对应页面替换引导页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 ## 文案 diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index c8fad0215c..634c4c32ba 100644 --- a/packages/client/ui-sidebar-right/package.json +++ b/packages/client/ui-sidebar-right/package.json @@ -67,7 +67,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "picomatch": "^4.0.4", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "@deepseek-ai/dsh-util-crypto": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 38704ee48d..def6c222cc 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -27,6 +27,7 @@ * The registration adopts Session stores and injects the mounted seat binding; * callers use the service's navigation methods. */ +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' import { activeDockPaneId, canSplit, findContentTab, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' @@ -344,7 +345,7 @@ export class SidebarRightController implements ISidebarRight { ): void { const definition = this.tabs.get(kind) if (definition === undefined) throw new Error(`sidebarRight: no tab type is registered as "${kind}"`) - const address = pageAddress(kind) + const address = definition.multiple === true ? `${pageAddress(kind)}/${randomUUID()}` : pageAddress(kind) this.place(sessionId, actions, { kind, contentId: address, title: definition.title(address) }, address, options, options.params) } diff --git a/packages/client/ui-sidebar-right/src/client/tab-registry.ts b/packages/client/ui-sidebar-right/src/client/tab-registry.ts index 5284716d5f..13c4490308 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-registry.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-registry.ts @@ -68,8 +68,6 @@ export interface SidebarRightGuideEntry { readonly title: () => string /** Optional glyph, drawn before the title. */ readonly icon?: ComponentType - /** Defaults to true; false makes this entry create another tab instead of focusing an existing one. */ - readonly revealIfOpened?: boolean } /** A guide entry as the registry lists it: with the kind of the type that contributed it, which is what picking it opens. */ @@ -90,6 +88,8 @@ export interface SidebarRightTabDefinition { readonly id: string /** Type discriminator: what the tabs of this type are, and what `openTab` names. */ readonly kind: string + /** Each open by kind creates independent content; omission keeps one page per kind in each pane. */ + readonly multiple?: boolean /** * Resource-address globs this type recognizes; omit for a page type, which is * opened by kind and recognizes no address. diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx index b3d07685dd..5a9851739a 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx @@ -67,10 +67,7 @@ export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain }: Guid const options = { hookContext: useTabInfo, fallback: ( - { tab.actions.openTab(entry.kind, { - replaceTab: true, - ...entry.revealIfOpened === undefined ? {} : { revealIfOpened: entry.revealIfOpened }, - }) }} /> + { tab.actions.openTab(entry.kind, { replaceTab: true }) }} /> ), } satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> } return renderSlotChain('sidebar.right.tab.guide', {}, options) diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index 3f88ad5e6a..2648088cc4 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -82,11 +82,6 @@ describe('GuideBody', () => { cleanup() }) - it.each([false, true])('passes an entry\'s explicit revealIfOpened=%s preference when replacing the guide', (revealIfOpened) => { - const { view, openTab } = mountGuide([{ ...box('terminal', 20), revealIfOpened }]) - fireEvent.click(view.getByRole('button', { name: 'terminal title' })) - expect(openTab).toHaveBeenCalledExactlyOnceWith('terminal', { replaceTab: true, revealIfOpened }) - }) it('draws an empty guide while no type contributed an entry, and follows the registry when one does', () => { const { view, guideEntries, boxes } = mountGuide([]) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 5624fa70ff..de35bc78f0 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -80,6 +80,25 @@ function harness() { } describe('SidebarRightController — opening', () => { + it('keeps independently opened instances distinct when they return to the same pane', () => { + const h = harness() + h.tabs.register({ id: 'test/terminal', kind: 'terminal', multiple: true, title: () => 'terminal' }) + const release = h.publish() + try { + h.controller.openTab('terminal') + h.controller.openTab('terminal') + const terminals = Object.values(h.layout().tabs).filter(tab => tab.kind === 'terminal') + expect(terminals).toHaveLength(2) + expect(terminals[0]!.contentId).not.toBe(terminals[1]!.contentId) + const pane = findTabPane(h.layout(), terminals[0]!.id).id + h.instance.actions.floatTab(SESSION, terminals[0]!.id) + const floating = findTabPane(h.layout(), terminals[0]!.id).id + h.instance.actions.unfloatPane(SESSION, floating) + expect(getPane(h.layout(), pane).tabs).toContain(terminals[0]!.id) + expect(getPane(h.layout(), pane).tabs).toContain(terminals[1]!.id) + } finally { release() } + }) + it('refuses every write while no seat is mounted', () => { const { controller } = harness() expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') diff --git a/packages/client/ui-sidebar-terminal/src/client/index.ts b/packages/client/ui-sidebar-terminal/src/client/index.ts index ce38b0feb0..0ba160121c 100644 --- a/packages/client/ui-sidebar-terminal/src/client/index.ts +++ b/packages/client/ui-sidebar-terminal/src/client/index.ts @@ -37,8 +37,8 @@ export function apply(ctx: Context): void { const t = ctx.locale.bind(namespace) ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-terminal.copy') ctx.effect(() => ctx.sidebarRightTabs.register({ - id, kind: 'terminal', priority: 'builtin', title: () => t('title'), - guide: [{ order: 20, title: () => t('new'), icon: TerminalIcon, revealIfOpened: false }], + id, kind: 'terminal', multiple: true, priority: 'builtin', title: () => t('title'), + guide: [{ order: 20, title: () => t('new'), icon: TerminalIcon }], }), 'ui-sidebar-terminal.type') ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => { ctx.webTerminals.close(sessionId, tab.id, terminalId(sessionId, tab.id)) @@ -62,7 +62,7 @@ export function apply(ctx: Context): void { pending = ctx.webTerminals.recover(sessionId).then((terminals) => { if (disposed) return for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', { - revealIfOpened: false, params: { terminalId: info.id }, + params: { terminalId: info.id }, }) }).catch((error: unknown) => { recovered.delete(sessionId); throw error }) recovered.set(sessionId, pending) diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts index 3fe6dd4e4d..70d7f1d473 100644 --- a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -69,7 +69,8 @@ it('registers terminal views, recovery and cleanup, then releases every contribu try { const definition = h.tabs.get('terminal')! expect(definition.title('sidebar://terminal')).toBe('title') - expect(definition.guide?.map(entry => [entry.order, entry.title(), entry.revealIfOpened])).toEqual([[20, 'new', false]]) + expect(definition.guide?.map(entry => [entry.order, entry.title()])).toEqual([[20, 'new']]) + expect(definition.multiple).toBe(true) expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh }) expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([ ['sidebar.right.pane.tab', TerminalBody, 'sidebarTerminal'], @@ -120,8 +121,8 @@ it('shares pending and completed recovery across Session headers and opens each pending.resolve([terminalInfo('build'), terminalInfo('tests')]) await completion expect(h.openTabIn.mock.calls).toEqual([ - [sessionId, 'terminal', { revealIfOpened: false, params: { terminalId: 'build' } }], - [sessionId, 'terminal', { revealIfOpened: false, params: { terminalId: 'tests' } }], + [sessionId, 'terminal', { params: { terminalId: 'build' } }], + [sessionId, 'terminal', { params: { terminalId: 'tests' } }], ]) await remounted.restore() expect(h.terminals.recover).toHaveBeenCalledTimes(1) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index e04684be10..7873cd4653 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2475,9 +2475,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the Session workspace directory and terminal limits.', }, { - signature: '@Remote list(agent: Agent): WebTerminalInfo[]', - description: 'List running and exited terminals available for reattachment.', - parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }], + signature: '@Remote list(sessionId: SessionId): WebTerminalInfo[]', + description: 'List retained terminals without resolving or activating an Agent.', + parameters: [{ name: 'sessionId', description: 'displayed Session identity, including offline history.' }], returns: 'terminals retained for this Host lifetime.', }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5fc141f2b..05c8eeb0c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,6 +621,9 @@ importers: '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/shell/pwsh-local + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../packages/subprocess/subprocess-local '@types/node': specifier: ^22.0.0 version: 22.20.0 @@ -4040,6 +4043,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) From fabbcf8c7616a335bbb21eec89a5fbb900f6bad6 Mon Sep 17 00:00:00 2001 From: turtle1999 <301751225+turtle1999@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:38:12 +0800 Subject: [PATCH 27/38] test(ci): cover control requests and fix cross-volume fixtures --- packages/fs/fs-local/tests/fsio.spec.ts | 3 ++- .../subprocess-local/tests/linux-scope.spec.ts | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index be3bef5b5b..40ed836309 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -110,7 +110,8 @@ describe('resolveLocalTarget', () => { await mkdir(join(dir, 'nested')) await writeFile(join(dir, 'created.txt'), 'native') const drive = parse(dir).root.slice(0, 2) - const driveCwd = `${drive}${relative(process.cwd(), dir)}` + // Resolve the temp drive independently when the checkout lives on another volume. + const driveCwd = `${drive}${relative(resolve(drive), dir)}` for (const cwd of [dir, driveCwd]) { for (const requested of ['created.txt', `${drive}nested/../created.txt`]) { const target = await resolveLocalTarget(cwd, requested) diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 0f3c4c79ac..be351e4d74 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -1,3 +1,4 @@ +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { EventEmitter } from 'node:events' import { existsSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' import { PassThrough } from 'node:stream' @@ -42,6 +43,8 @@ class FakeChild extends EventEmitter { stdin = new PassThrough() stdout = new PassThrough() stderr = new PassThrough() + control = new PassThrough() + stdio = [this.stdin, this.stdout, this.stderr, null, null, null, null, this.control] kills: NodeJS.Signals[] = [] kill(signal: NodeJS.Signals): boolean { @@ -111,6 +114,7 @@ function spec() { function launch( query: LinuxScopeInternals['systemctlQuery'], overrides: LinuxScopeInternals = {}, + request: SubprocessSpawnSpec = spec(), ) { const child = new FakeChild() let options: { env?: NodeJS.ProcessEnv; cwd?: string; detached?: boolean } | undefined @@ -120,7 +124,7 @@ function launch( }) const spawnSync = vi.fn(() => ({ status: 0, stdout: '', stderr: '' })) const systemctlQuery = overrides.systemctlQuery ?? query - const result = launchLinuxScope(spec(), { TARGET: 'yes' }, { + const result = launchLinuxScope(request, { TARGET: 'yes' }, { spawn: overrides.spawn ?? spawn as never, spawnSync: overrides.spawnSync ?? spawnSync as never, ...systemctlQuery === undefined ? {} : { systemctlQuery }, @@ -255,10 +259,14 @@ describe('Linux scope establishment and quiescence', () => { result.owner.cleanup?.() }) - it('accepts request consumption followed by rapid --collect unload as stopped', async () => { + it.each([undefined, 'pipe'] as const)('accepts request consumption and rapid --collect unload with control %s', async (control) => { const states = [activeUnit(), unloadedUnit()] - const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit()) - expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } }) + const controlOptions = control === undefined ? {} : { control } + const { child, result, requestPath } = launch(async () => states.shift() ?? missingUnit(), {}, { + ...spec(), stdio: { ...spec().stdio, ...controlOptions }, + }) + expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' }, ...controlOptions }) + expect(result.control).toBe(control === undefined ? undefined : child.control) const waiting = result.owner.waitForExit() child.exit(0, null) await expect(result.direct).resolves.toEqual({ exitCode: 0, signal: null }) From c8370240a8f9bcccf287f5337d17ca1cc2903db5 Mon Sep 17 00:00:00 2001 From: turtle1999 <301751225+turtle1999@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:42:12 +0800 Subject: [PATCH 28/38] test(ci): pin gallery time and dispose projection cache fixtures --- apps/web/tests/clickable-links-gallery.e2e.ts | 8 +++++--- packages/subagent/subagent/tests/list-children.spec.ts | 5 ++++- .../subprocess/subprocess-local/tests/linux-scope.spec.ts | 1 - 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 081d183c5b..48285d1c65 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -45,6 +45,8 @@ const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.met const MODE = webSnapshotMode() const SEED_ID = 'clickable-links-gallery-web-e2e' const DONE = 'LINK_GALLERY_DONE' +// Seeded events and browser time share a day independently of host timezones. +const GALLERY_TIME = Date.UTC(2026, 0, 15, 12) const GUIDE_URL = 'https://docs.example.test/guide' const API_URL = 'https://docs.example.test/api' @@ -192,7 +194,6 @@ const CALLS: GalleryCall[] = [ */ function galleryFixture(imageUrl: string): string { const session = Session.create(SessionId('clickable-links-gallery-source')) - const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) session.append('turn/start', { turn: 1 }) const user = session.append('user/message', createUserMessage({ content: text('Assemble the link gallery: write the report and styles, inspect the sources, and summarize.'), @@ -289,7 +290,7 @@ function galleryFixture(imageUrl: string): string { }), ...session.snapshotEvents().map(event => JSON.stringify({ ...event, - time: eventTimeOrigin + event.seq * 1_000, + time: GALLERY_TIME + event.seq * 1_000, })), '', ].join('\n') @@ -305,9 +306,10 @@ describe('web e2e: clickable links gallery', () => { beforeAll(async () => { scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) imageUrl = new URL('/favicon.svg', scaffold.baseUrl).toString() - await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID) + await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME }) browser = await chromium.launch() page = await newEnglishPage(browser) + await page.clock.setFixedTime(GALLERY_TIME + 60_000) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 6d83648d23..2794f825bd 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -35,10 +35,12 @@ import { seedStoredSession } from './persistence-helpers.ts' type Script = ConstructorParameters[0] const roots: string[] = [] +const projectionCacheDisposers: Array<() => Promise> = [] const persistenceDisposers: Array<() => Promise> = [] const projCacheRoots: string[] = [] afterEach(async () => { + await Promise.all(projectionCacheDisposers.splice(0).map(dispose => dispose())) await Promise.all(persistenceDisposers.splice(0).map(dispose => dispose())) for (const root of projCacheRoots.splice(0)) rmSync(root, { recursive: true, force: true }) for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) @@ -64,7 +66,8 @@ async function setup( await ctx.plugin(Storage) await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root }) await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) - await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + const cache = await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + projectionCacheDisposers.push(() => cache.dispose()) } await ctx.plugin(TestSessionQuery) await ctx.plugin(SubagentRuntime) diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index 6b5cd534dc..8aea04d9f8 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -1,4 +1,3 @@ -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { EventEmitter } from 'node:events' import { existsSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' import { PassThrough } from 'node:stream' From 33d89aee7745f462263bd5be532fe802431ece71 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 14 Sep 2026 10:59:46 +0800 Subject: [PATCH 29/38] feat(web): choose and remember terminal shells --- .../2026-09-09-web-sidebar-terminal.i18n.yaml | 4 +- .../2026-09-09-web-sidebar-terminal.md | 4 +- .../2026-09-09-web-sidebar-terminal.zh.md | 4 +- .../sidebar-terminal/selection.expected.md | 5 ++ .../tests/fixtures/sidebar-terminal.patch.yml | 1 + apps/web/tests/sidebar-terminal.e2e.ts | 34 +++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 + docs/config-catalog.zh.md | 2 + docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 8 ++ docs/subsystems/workspace.zh.md | 8 ++ .../api/terminal-controller/README.i18n.yaml | 4 +- packages/api/terminal-controller/README.md | 7 +- packages/api/terminal-controller/README.zh.md | 7 +- packages/api/terminal-controller/package.json | 1 + .../terminal-controller/src/client/model.ts | 56 +++++++++--- .../src/client/shell-preference.ts | 20 +++++ packages/api/terminal-controller/src/index.ts | 26 +++++- .../api/terminal-controller/src/shells.ts | 31 ++++++- packages/api/terminal-controller/src/types.ts | 2 + .../tests/controller.spec.ts | 39 +++++++- .../tests/model.client.spec.ts | 6 ++ .../tests/recovery.client.spec.ts | 88 +++++++++++++++++-- .../terminal-controller/tsconfig.client.json | 1 + .../ui-sidebar-terminal/README.i18n.yaml | 4 +- packages/client/ui-sidebar-terminal/README.md | 6 +- .../client/ui-sidebar-terminal/README.zh.md | 4 +- .../src/client/TerminalBody.module.css | 3 + .../src/client/TerminalBody.tsx | 11 ++- .../ui-sidebar-terminal/src/client/locales.ts | 2 + .../tests/terminal-body.client.spec.tsx | 14 +++ .../extensions/tool-cordis/src/api-catalog.ts | 8 +- packages/ssh/ssh/src/helper-processes.ts | 13 +++ packages/ssh/ssh/src/helper.ts | 18 +++- packages/ssh/ssh/src/schemas.ts | 4 +- .../ssh/ssh/tests/helper-processes.spec.ts | 9 +- packages/ssh/ssh/tests/helper-runtime.spec.ts | 3 + packages/ssh/ssh/tests/live.e2e.ts | 2 +- .../ssh/ssh/tests/stream-capability.spec.ts | 4 +- .../ssh/ssh/tests/stream-lifetime.spec.ts | 2 +- packages/ssh/subprocess-ssh/README.i18n.yaml | 4 +- packages/ssh/subprocess-ssh/README.md | 2 + packages/ssh/subprocess-ssh/README.zh.md | 2 + packages/ssh/subprocess-ssh/src/index.ts | 28 ++++-- .../subprocess-ssh/tests/lifecycle.spec.ts | 2 +- .../tests/shell-discovery.spec.ts | 58 ++++++++++++ .../tests/terminal-behavior.spec.ts | 8 +- scripts/gen-cordis-catalog.ts | 1 + scripts/package-dependency-policy.ts | 1 + .../cordis-inspect-jsdoc/session.v3.jsonl | 2 +- 51 files changed, 514 insertions(+), 69 deletions(-) create mode 100644 apps/web/tests/expected/sidebar-terminal/selection.expected.md create mode 100644 packages/api/terminal-controller/src/client/shell-preference.ts create mode 100644 packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index 4151a21804..2a4d90b89c 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: d95968712e7f99b8e83d020273bb02818b4b2824 -2026-09-09-web-sidebar-terminal.zh.md: 2b8ed2fa1317dce510e01d840acd3b99f40eaccf +2026-09-09-web-sidebar-terminal.md: f922e8c889fea0fef1fafcd729b1a5489a8b0c42 +2026-09-09-web-sidebar-terminal.zh.md: e53c71e3f6c307e1afe2fc9f1f9561599ba41829 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index d95968712e..f922e8c889 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -10,7 +10,7 @@ Web users need an interactive shell beside a Session to inspect the workspace an ## Decision -`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal starts the execution environment's default shell immediately. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs only during creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. +`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last successful shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. @@ -22,7 +22,7 @@ The latest attachment controls input and dimensions; other attachments remain re ## Alternatives considered -**Add shell selection and terminal-specific creation controls.** Opening a terminal uses the execution environment's default shell, and the sidebar already owns tab creation and titles. Another picker, process list, header and add control would duplicate navigation around the terminal screen. +**Persist complete shell profiles in the browser.** Only the selected path is a user preference. Arguments and availability belong to Host discovery; persisting them would allow stale profiles to bypass current execution configuration. The sidebar continues to own the terminal list and tab controls. **Keep the tab visible until process cleanup finishes.** A slow or failed termination would delay the user's close action. Saving the cleanup intent allows immediate removal while preserving failure reporting and retry. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index 2b8ed2fa13..e53c71e3f6 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -10,7 +10,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 ## 决定 -`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端立即启动执行环境的默认 shell。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 只在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 +`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次成功启动的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 @@ -22,7 +22,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 ## 考虑过的替代方案 -**增加 shell 选择和终端专用创建控件。** 打开终端使用执行环境的默认 shell,侧栏已经负责标签页创建与标题。额外的选择器、进程列表、标题栏和添加控件会在终端屏幕周围重复导航功能。 +**在浏览器保存完整 shell profile。** 用户偏好只包含选中的路径。参数和可用性由 Host 探测决定;持久化这些信息会让过期 profile 绕过当前执行配置。终端列表和标签页控件仍由侧栏负责。 **进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。 diff --git a/apps/web/tests/expected/sidebar-terminal/selection.expected.md b/apps/web/tests/expected/sidebar-terminal/selection.expected.md new file mode 100644 index 0000000000..5b64f44658 --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/selection.expected.md @@ -0,0 +1,5 @@ +- text: Shell +- combobox "Shell": + - option "bash — /bin/bash" [selected] + - option "sh — /bin/sh" +- button "Start terminal" diff --git a/apps/web/tests/fixtures/sidebar-terminal.patch.yml b/apps/web/tests/fixtures/sidebar-terminal.patch.yml index cc5d4b94f3..8b96197c79 100644 --- a/apps/web/tests/fixtures/sidebar-terminal.patch.yml +++ b/apps/web/tests/fixtures/sidebar-terminal.patch.yml @@ -1,6 +1,7 @@ - id: terminal-controller config: maxTerminals: 2 + shellCandidates: [/bin/bash, /bin/sh] shell: path: /bin/bash name: bash diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index 73030f48be..072801c58d 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -19,6 +19,7 @@ async function openTerminal(page: Page, waitForShell = true): Promise { const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]') if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click() await entry.click() + await page.getByRole('button', { name: 'Start terminal', exact: true }).click() if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-') } @@ -157,6 +158,39 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { expect(tripwire.pageErrors).toEqual([]) }) + it('offers installed shells, remembers the choice after reload, and restores without a picker', async () => { + onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice')) + await page.locator('[data-sidebar-right-expand]').click() + await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() + const selector = page.getByRole('combobox', { name: 'Shell', exact: true }) + await selector.waitFor() + expect(await selector.inputValue()).toBe('/bin/bash') + expect(handles).toHaveLength(0) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)), + await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode()) + await selector.selectOption('/bin/sh') + await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true }) + await page.getByRole('button', { name: 'Start terminal', exact: true }).click() + await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"") + const screen = page.locator('.xterm-rows:visible') + await expect.poll(() => screen.innerText()).toContain('CHOSEN_SHELL:/bin/sh') + expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh') + const retained = processIdentity(0) + await page.reload({ waitUntil: 'load' }) + await page.locator('.xterm-helper-textarea:visible').waitFor() + expect(await selector.count()).toBe(0) + expect(alive(retained)).toBe(true) + await page.locator('[data-dockkit-add-tab]').click() + await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() + await selector.waitFor() + expect(await selector.inputValue()).toBe('/bin/sh') + expect(handles).toHaveLength(1) + await page.getByRole('button', { name: 'Start terminal', exact: true }).click() + await expect.poll(() => handles.length).toBe(2) + expect(scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).map(info => info.shell.path)).toEqual(['/bin/sh', '/bin/sh']) + expect(tripwire.pageErrors).toEqual([]) + }) + it('explains that exited terminals count toward the quota and permits creation after closing one', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota')) const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4100624182..70f495f129 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 5c550cc5d36213da9ad5705d756f4a1ef163ee16 -config-catalog.zh.md: 06a98df3e00100025c668534d2b2f223efa11bdb +config-catalog.md: a812da090af38b958b6bd1afffd97d0377c963ea +config-catalog.zh.md: 0bf9e3b49a07782e9a647be08046440d34cd95ff diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5c550cc5d3..a812da090a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -247,6 +247,8 @@ export interface Config { /** Arguments passed to the interactive shell. */ args: string[] } | undefined + /** Executable names or paths checked for the new-terminal shell selector. */ + readonly shellCandidates: string[] /** Maximum retained terminals and pending allocations per Session. */ readonly maxTerminals: number /** Maximum terminal width in columns. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 06a98df3e0..0bf9e3b49a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -249,6 +249,8 @@ export interface Config { /** Arguments passed to the interactive shell. */ args: string[] } | undefined + /** Executable names or paths checked for the new-terminal shell selector. */ + readonly shellCandidates: string[] /** Maximum retained terminals and pending allocations per Session. */ readonly maxTerminals: number /** Maximum terminal width in columns. */ diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index ea153ba123..b9004c4754 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: ee5486800c2b0f01ab3cedde10e1b5ca30968950 -workspace.zh.md: 34d0e6f1cc2b00ee2ae8dd737b45d6a20061c107 +workspace.md: 77e5dd39afb6d2840751d6d56a7a50f9fea2adaa +workspace.zh.md: ad226e34b4fb508e9ce68e90349848d28632d559 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index ee5486800c..77e5dd39af 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -198,6 +198,14 @@ Typed Remote control of transient Session-owned terminal processes. */ @Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment +/** + * Discover installed shells in the Session's execution environment. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns verified profiles, with the configured or system default first. + */ +@Remote shells(agent: Agent, signal: AbortSignal): Promise + /** * List retained terminals without resolving or activating an Agent. * @param sessionId - displayed Session identity, including offline history. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 34d0e6f1cc..ad226e34b4 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -198,6 +198,14 @@ Typed Remote control of transient Session-owned terminal processes. */ @Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment +/** + * Discover installed shells in the Session's execution environment. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns verified profiles, with the configured or system default first. + */ +@Remote shells(agent: Agent, signal: AbortSignal): Promise + /** * List retained terminals without resolving or activating an Agent. * @param sessionId - displayed Session identity, including offline history. diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index b3c9756b81..618b771218 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md -README.md: e6ffceff8cd3c16ad7a6d61697dec870bba0fe14 -README.zh.md: 609493cfc0fa127473538bc6ce79e6083f6d8703 +README.md: a24cb6e572782b59b83b836f0f477358abadf347 +README.zh.md: b979a7913e3b7fbcd1330ed7459eea9423348941 diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index e6ffceff8c..a24cb6e572 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -25,13 +25,14 @@ Open the execution environment's default shell in a Session workspace from the W ## Use this package -The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. +The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error. -New terminals use the execution environment's declared default shell. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The provider verifies the executable during creation; resolution failure is reported without trying another shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal. +Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal. | Configuration | Default | Meaning | |---|---|---| | `shell` | omitted | Use the execution environment's default shell, or one explicit profile | +| `shellCandidates` | `zsh`, `bash`, `fish`, `sh`, `ksh`, `tcsh`, `csh`, `pwsh`, `powershell`, `cmd`, `nu` | Additional executable names or paths offered when installed | | `maxTerminals` | `8` | Retained terminals and pending allocations per Session | | `maxCols`, `maxRows` | `500`, `200` | Maximum PTY dimensions | | `scrollback` | `1000` | Retained screen history rows | @@ -51,6 +52,8 @@ The latest attachment owns input and resize. Detachment releases input control w Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. +New views wait for explicit shell selection and start. The last successful shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. A saved path is preselected only while Host discovery still lists it; otherwise the current default is selected. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells. + Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload. diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index 609493cfc0..b979a7913e 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -25,13 +25,14 @@ kind: "package-reference" ## 使用此包 -Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 +Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。 -新终端使用执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。provider 在创建时验证可执行文件;解析失败会直接报告,不尝试其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。 +Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。 | 配置 | 默认值 | 含义 | |---|---|---| | `shell` | 省略 | 使用执行环境默认 shell,或指定一个 profile | +| `shellCandidates` | `zsh`、`bash`、`fish`、`sh`、`ksh`、`tcsh`、`csh`、`pwsh`、`powershell`、`cmd`、`nu` | 已安装时供用户选择的额外可执行名称或路径 | | `maxTerminals` | `8` | 每个 Session 保留的终端和创建请求上限 | | `maxCols`、`maxRows` | `500`、`200` | PTY 最大尺寸 | | `scrollback` | `1000` | 屏幕历史行数 | @@ -51,6 +52,8 @@ Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终 Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 +新视图等待用户选择 shell 并启动。上次成功启动的 shell 路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。仅当 Host 探测仍列出该路径时才预选它,否则选择当前默认项。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。 + 关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。 diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index 46e22a9708..0504a7dfcc 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -62,6 +62,7 @@ "zod": "^4.4.3" }, "peerDependencies": { + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { diff --git a/packages/api/terminal-controller/src/client/model.ts b/packages/api/terminal-controller/src/client/model.ts index 7c19a4cac8..1150718f1f 100644 --- a/packages/api/terminal-controller/src/client/model.ts +++ b/packages/api/terminal-controller/src/client/model.ts @@ -1,4 +1,5 @@ /** React-free browser terminal state and reconnecting Remote-stream ownership. */ +import { preferredShell, rememberShell } from './shell-preference.ts' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client' @@ -6,7 +7,7 @@ import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh- import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - TerminalAttachmentId, TerminalEnvironment, TerminalFrame, + TerminalShell, TerminalAttachmentId, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo, } from '../types.ts' @@ -35,7 +36,9 @@ export interface TerminalRenderFrame { /** Observable state of one sidebar occurrence. */ export interface TerminalViewState { - readonly phase: 'idle' | 'loading' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed' + readonly phase: 'idle' | 'loading' | 'selecting' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed' + readonly shells?: readonly TerminalShell[] | undefined + readonly selectedShell?: string | undefined readonly environment?: TerminalEnvironment | undefined readonly title?: string | undefined readonly info?: WebTerminalInfo | undefined @@ -78,7 +81,7 @@ export class TerminalView { ) {} /** - * Attach the DOM lifetime, starting the default shell or reconnecting the saved process. + * Attach the DOM lifetime, loading shell choices or reconnecting the saved process. * @returns a detach callback that leaves the terminal process alive. */ mount(): () => void { @@ -97,6 +100,7 @@ export class TerminalView { * @returns after environment lookup and creation or recovery settle. */ refresh(): Promise { + if (this.creation !== undefined) return this.creation if (this.loading !== undefined) return this.loading if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() this.patch({ phase: 'loading', error: undefined, issue: undefined }) @@ -104,24 +108,56 @@ export class TerminalView { const [environment, available] = await Promise.all([ this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId), ]) - if (this.lifetime.signal.aborted || this.closing !== undefined) return + if (this.stopped()) return this.patch({ environment: valueOf(environment) }) const info = valueOf(available).find(item => item.id === this.id) if (info !== undefined) this.adopt(info) - else if (this.createWhenMissing) await this.create(valueOf(environment)) + else if (this.createWhenMissing) { + const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal)) + if (this.stopped()) return + const previous = this.state.getSnapshot().selectedShell ?? preferredShell() + const selectedShell = shells.find(shell => shell.path === previous)?.path ?? shells[0]?.path + this.patch({ phase: 'selecting', shells, selectedShell }) + } else throw new TerminalViewError('missingTerminal') })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined }) return this.loading } - private async create(environment: TerminalEnvironment): Promise { + /** + * Select one verified shell before starting the terminal. + * @param path - executable path offered by Host discovery. + */ + selectShell(path: string): void { + const state = this.state.getSnapshot() + if (state.phase === 'selecting' && state.shells?.some(shell => shell.path === path)) this.patch({ selectedShell: path }) + } + + /** + * Start the selected shell once; failed attempts retain their selection for retry. + * @returns after allocation settles; failures are exposed in observable state. + */ + start(): Promise { + if (this.creation !== undefined) return this.creation + const state = this.state.getSnapshot() + if (state.phase !== 'selecting' || state.environment === undefined || state.selectedShell === undefined + || this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() + return this.create(state.environment, state.selectedShell) + } + + private stopped(): boolean { return this.lifetime.signal.aborted || this.closing !== undefined } + + private async create(environment: TerminalEnvironment, shellPath: string): Promise { this.patch({ phase: 'creating', error: undefined, issue: undefined }) this.creation = (async () => { const info = valueOf(await this.remote.create(this.sessionId, { - id: this.id, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows), + id: this.id, shellPath, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows), }, this.lifetime.signal)) - if (!this.lifetime.signal.aborted) this.adopt(info) - })().finally(() => { this.creation = undefined }) + if (!this.lifetime.signal.aborted) { + rememberShell(info.shell.path) + this.adopt(info) + } + })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.creation = undefined }) await this.creation } @@ -221,7 +257,7 @@ export class TerminalView { this.detach() this.closing = (async () => { // Even a refused or lost creation response may leave an allocation to close. - await this.creation?.catch(() => {}) + await this.creation valueOf(await this.remote.close(this.sessionId, this.id)) this.detach() this.patch({ phase: 'closed', writable: false }) diff --git a/packages/api/terminal-controller/src/client/shell-preference.ts b/packages/api/terminal-controller/src/client/shell-preference.ts new file mode 100644 index 0000000000..044a41d845 --- /dev/null +++ b/packages/api/terminal-controller/src/client/shell-preference.ts @@ -0,0 +1,20 @@ +/** Browser-local shell preference; Host discovery decides whether the saved path is usable. */ +const KEY = 'dsh.terminal.shell' + +/** + * Read the browser preference. + * @returns the last successfully started shell path, or null when storage is unavailable. + */ +export function preferredShell(): string | null { + try { return typeof localStorage === 'undefined' ? null : localStorage.getItem(KEY) } + catch (_storageUnavailable) { return null } +} + +/** + * Remember a successful shell launch without making storage a startup dependency. + * @param path - verified executable path returned by the Host. + */ +export function rememberShell(path: string): void { + try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, path) } + catch (_storageUnavailable) { /* Private browsing or quota failure leaves this launch usable. */ } +} diff --git a/packages/api/terminal-controller/src/index.ts b/packages/api/terminal-controller/src/index.ts index 7b854476b7..920982897e 100644 --- a/packages/api/terminal-controller/src/index.ts +++ b/packages/api/terminal-controller/src/index.ts @@ -8,10 +8,10 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-sandbox' import type {} from '@deepseek-ai/dsh-session-projection' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import { resolveShell } from './shells.ts' +import { discoverShells, resolveShell } from './shells.ts' import { BrowserTerminal } from './terminal.ts' import type { - TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, + TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo, } from './types.ts' @@ -35,6 +35,8 @@ export interface Config { /** Arguments passed to the interactive shell. */ args: string[] } | undefined + /** Executable names or paths checked for the new-terminal shell selector. */ + readonly shellCandidates: string[] /** Maximum retained terminals and pending allocations per Session. */ readonly maxTerminals: number /** Maximum terminal width in columns. */ @@ -67,6 +69,7 @@ export class TerminalController extends TypertRemoteService { shell: z.union([z.object({ path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]), }), z.const(undefined)]), + shellCandidates: z.array(z.string().min(1)).default(['zsh', 'bash', 'fish', 'sh', 'ksh', 'tcsh', 'csh', 'pwsh', 'powershell', 'cmd', 'nu']), maxTerminals: z.number().step(1).min(1).default(8), maxCols: z.number().step(1).min(2).default(500), maxRows: z.number().step(1).min(1).default(200), @@ -117,6 +120,18 @@ export class TerminalController extends TypertRemoteService { maxRows: this.config.maxRows, scrollback: this.config.scrollback } } + /** + * Discover installed shells in the Session's execution environment. + * @param agent - Session owner supplied by the Gateway. + * @param signal - request cancellation. + * @returns verified profiles, with the configured or system default first. + */ + @Remote + shells(agent: Agent, signal: AbortSignal): Promise { + signal.throwIfAborted() + return discoverShells(this.execution(agent).subprocess, this.config.shell, this.config.shellCandidates, signal) + } + /** * List retained terminals without resolving or activating an Agent. * @param sessionId - displayed Session identity, including offline history. @@ -301,13 +316,16 @@ export class TerminalController extends TypertRemoteService { private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise { const environment = this.environment(agent, signal) const { subprocess, sandboxPolicy } = this.execution(agent) - const shell = await resolveShell(subprocess, this.config.shell, signal) + const shell = request.shellPath === undefined + ? await resolveShell(subprocess, this.config.shell, signal) + : (await this.shells(agent, signal)).find(candidate => candidate.path === request.shellPath) + if (shell === undefined) throw new Error('Selected shell is not available in this execution environment') const policy = sandboxPolicy.resolve({ session: agent.session }) let argv = [shell.path, ...shell.args] if (policy.mode !== 'danger-full-access') { const sandbox = agent.ctx.get('sandbox') if (sandbox === undefined) throw new Error('The Session sandbox mode requires an execution sandbox provider') - argv = sandbox.confine(argv, { ...policy, mode: policy.mode }).argv + argv = (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv } const handle = await subprocess.spawnTerminal({ argv, cwd: environment.cwd, cols: request.cols, rows: request.rows, diff --git a/packages/api/terminal-controller/src/shells.ts b/packages/api/terminal-controller/src/shells.ts index 284a46f319..c8010c38c4 100644 --- a/packages/api/terminal-controller/src/shells.ts +++ b/packages/api/terminal-controller/src/shells.ts @@ -1,5 +1,5 @@ /** Shell selection and executable verification use the target execution provider. */ -import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' +import { SubprocessExecutableNotFoundError, type SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' import type { TerminalShell } from './types.ts' /** @@ -26,3 +26,32 @@ function profile(path: string): TerminalShell { const kind = name.toLowerCase().replace(/\.exe$/u, '') return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] } } + +/** + * List verified candidates after the configured or environment-default shell. + * @param subprocess - target execution provider. + * @param configured - optional default profile. + * @param candidates - executable names or paths permitted for shell selection. + * @param signal - discovery cancellation. + * @returns unique installed shells, with the default first; transport failures reject. + */ +export async function discoverShells( + subprocess: SubprocessRuntime, configured: TerminalShell | undefined, + candidates: readonly string[], signal: AbortSignal, +): Promise { + const preferred = await resolveShell(subprocess, configured, signal) + const found = await Promise.all(candidates.map(async (candidate) => { + try { return await resolveShell(subprocess, profile(candidate), signal) } + catch (error) { + if (error instanceof SubprocessExecutableNotFoundError) return undefined + throw error + } + })) + const shells = new Map() + for (const shell of [preferred, ...found]) { + if (shell === undefined) continue + const key = shell.path.includes('\\') ? shell.path.toLowerCase() : shell.path + if (!shells.has(key)) shells.set(key, shell) + } + return [...shells.values()] +} diff --git a/packages/api/terminal-controller/src/types.ts b/packages/api/terminal-controller/src/types.ts index b474c9ad47..fac1f4363c 100644 --- a/packages/api/terminal-controller/src/types.ts +++ b/packages/api/terminal-controller/src/types.ts @@ -49,6 +49,8 @@ export interface WebTerminalInfo { /** Create is idempotent for an open identity; closed identities cannot be recreated. */ export interface TerminalCreateRequest { + /** A path returned by shell discovery; absent selects the execution default. */ + readonly shellPath?: string readonly id: WebTerminalId readonly cols: number readonly rows: number diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index 619750f9ff..a42ac6c44e 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -16,7 +16,7 @@ import type { TerminalAttachmentId, WebTerminalId } from '../src/types.ts' const roots: Context[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) }) -const config: Config = { shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100 } +const config: Config = { shellCandidates: ['zsh', 'bash', 'sh'], shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100 } const id = 'test-terminal' as WebTerminalId const request = { id, cols: 80, rows: 24 } const signal = (): AbortSignal => new AbortController().signal @@ -288,7 +288,7 @@ describe('TerminalController', () => { const confine = vi.fn((argv: readonly string[]) => ({ argv: ['sandbox-runner', ...argv] })) ctx.provide('sandbox', { confine } as never) await controller.create(agent, request, signal()) - expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy) + expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy, expect.any(AbortSignal)) expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['sandbox-runner', '/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 })) }) @@ -506,3 +506,38 @@ it.skipIf(process.platform === 'win32')('runs a real interactive shell with comp await rm(cwd, { recursive: true, force: true }) } }) + +it('discovers installed shells once per path, preserves default arguments, and refuses unlisted paths', async () => { + const h = fixture({ shellCandidates: ['bash', 'zsh', 'missing'] }) + h.subprocess.resolveExecutable.mockImplementation(async (path) => { + if (path === 'missing') throw new SubprocessExecutableNotFoundError('absent') + return path.startsWith('/') ? path : `/bin/${path}` + }) + const shells = await h.controller.shells(h.agent, signal()) + expect(shells).toEqual([config.shell, { path: '/bin/zsh', name: 'zsh', args: ['-i'] }]) + expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled() + await expect(h.controller.create(h.agent, { ...request, shellPath: '/bin/unlisted' }, signal())).rejects.toThrow('Selected shell is not available') + expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled() + const created = await h.controller.create(h.agent, { ...request, shellPath: '/bin/zsh' }, signal()) + expect(created.shell.path).toBe('/bin/zsh') + expect(h.subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/bin/zsh', '-i'] })) + h.subprocess.resolveExecutable.mockRejectedValue(new Error('SSH disconnected')) + await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('SSH disconnected') + expect(await h.controller.create(h.agent, { ...request, shellPath: '/bin/bash' }, signal())).toBe(created) +}) + +it('propagates optional-shell discovery transport errors and cancellation', async () => { + const h = fixture({ shellCandidates: ['fish'] }) + h.subprocess.resolveExecutable.mockImplementation(async (path) => { + if (path === 'fish') throw new Error('lookup transport failed') + return path + }) + await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('lookup transport failed') + const cancelled = AbortSignal.abort(new Error('cancelled discovery')) + expect(() => h.controller.shells(h.agent, cancelled)).toThrow('cancelled discovery') +}) + +it('deduplicates Windows executable paths regardless of letter case', async () => { + const h = fixture({ shell: { path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }, shellCandidates: ['c:\\windows\\cmd.exe'] }) + expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }]) +}) diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts index 4ce6fd6b8c..ac1a7243f9 100644 --- a/packages/api/terminal-controller/tests/model.client.spec.ts +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -31,6 +31,7 @@ function failure(message: string): RemoteResult { function fixture(prepareStream?: (stream: RemoteStream) => void) { const remote: TerminalRemote = { + shells: vi.fn(async () => success([info.shell])), environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_sessionId, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), @@ -61,6 +62,7 @@ function fixture(prepareStream?: (stream: RemoteStream) => void) { async function mount(model: TerminalView) { const detach = model.mount() await model.refresh() + await model.start() return detach } @@ -434,6 +436,7 @@ it('classifies a discovery carrier failure as disconnected and supports explicit expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', error: 'offline' }) expect(remote.create).not.toHaveBeenCalled() await model.refresh() + await model.start() expect(model.state.getSnapshot().info?.id).toBe(info.id) expect(remote.create).toHaveBeenCalledOnce() }) @@ -446,12 +449,14 @@ it('retains state when disposal overtakes successful allocation or a failed disc if (operation === 'creation') vi.mocked(remote.create).mockReturnValueOnce(creation.promise) else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise) const loading = model.refresh() + const starting = operation === 'creation' ? loading.then(() => model.start()) : undefined if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce() await model.dispose() const before = model.state.getSnapshot() if (operation === 'creation') creation.resolve(success(info)) else discovery.reject(new Error('late discovery failure')) await loading + await starting expect(model.state.getSnapshot()).toBe(before) expect(remote.follow).not.toHaveBeenCalled() } @@ -555,6 +560,7 @@ it('exposes a localized quota error and clears it after a successful retry', asy const { model, remote } = fixture() vi.mocked(remote.create).mockResolvedValueOnce({ ok: false, error: new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: 8 }) }) await model.refresh() + await model.start() expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'terminalLimit' }) await connected(model) expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined }) diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts index 28db262070..d58a9e26ba 100644 --- a/packages/api/terminal-controller/tests/recovery.client.spec.ts +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -28,6 +28,7 @@ function storage() { function fixture() { const remote: TerminalRemote = { + shells: vi.fn(async () => success([info.shell])), environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_session, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), @@ -58,7 +59,7 @@ function fixture() { return { remote, view, service } } -it('starts the default shell once across overlapping mounts and retries, with no shell choice in the request', async () => { +it('waits for shell selection and deduplicates overlapping starts', async () => { const h = fixture() const model = h.view() const creation = Promise.withResolvers>() @@ -66,10 +67,15 @@ it('starts the default shell once across overlapping mounts and retries, with no model.mount() const loading = model.refresh() expect(model.refresh()).toBe(loading) - await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1) - expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, cols: 80, rows: 24 }) - creation.resolve(success(info)) await loading + expect(h.remote.create).not.toHaveBeenCalled() + const starting = model.start() + const remounting = model.refresh() + void model.start() + expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, shellPath: info.shell.path, cols: 80, rows: 24 }) + creation.resolve(success(info)) + await starting + await remounting await expect.poll(() => model.state.getSnapshot().writable).toBe(true) expect(h.remote.create).toHaveBeenCalledOnce() }) @@ -105,8 +111,10 @@ it('retries lost create acknowledgements with the saved id and closes after a re const model = h.view() vi.mocked(h.remote.create).mockResolvedValueOnce(failure('response lost')) await model.refresh() + await model.start() expect(model.state.getSnapshot().error).toBe('response lost') await model.refresh() + await model.start() expect(vi.mocked(h.remote.create).mock.calls.map(call => call[1].id)).toEqual([info.id, info.id]) await model.close() expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) @@ -118,12 +126,15 @@ it('waits for an in-flight creation while close detaches immediately and prevent const creation = Promise.withResolvers>() vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise) const unmount = model.mount() + await model.refresh() + const starting = model.start() await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1) const closing = model.close() expect(model.close()).toBe(closing) unmount() expect(h.remote.close).not.toHaveBeenCalled() creation.resolve(failure('cancelled allocation')) + await starting await closing expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id) expect(h.remote.follow).not.toHaveBeenCalled() @@ -323,7 +334,7 @@ it('saves close intents without persisting any active terminal or sidebar state' const pending = Promise.withResolvers>() vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise) service.close(sessionId, 'new-tab') - const request = { sessionId, id: model.id, title: info.title } + const request = { sessionId, id: model.id, title: 'new-tab' } expect([...data.entries()]).toEqual([[`dsh.terminal.close.v1.${model.id}`, JSON.stringify(request)]]) pending.resolve(success(undefined)) await expect.poll(() => data.size).toBe(0) @@ -414,6 +425,8 @@ it('waits for both active and detached stream finalizers during plugin disposal }) const model = service.view(sessionId, 'tab') model.mount() + await model.refresh() + await model.start() await expect.poll(() => model.state.getSnapshot().writable).toBe(true) model.connect() await started[0]!.promise @@ -431,3 +444,68 @@ it('waits for both active and detached stream finalizers during plugin disposal expect(disposed).toBe(true) expect(h.remote.close).not.toHaveBeenCalled() }) + +it('remembers a successful shell choice for new views and falls back when that shell is absent', async () => { + const data = storage() + const h = fixture() + const alternate = { name: 'bash', path: '/bin/bash', args: ['-i'] } + vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell, alternate])) + vi.mocked(h.remote.create).mockImplementation(async (_sessionId, request) => success({ ...info, shell: alternate, id: request.id })) + const first = h.view() + await first.start() + first.selectShell(alternate.path) + await first.refresh() + expect(first.state.getSnapshot()).toMatchObject({ phase: 'selecting', selectedShell: info.shell.path }) + first.selectShell('/not-listed') + expect(first.state.getSnapshot().selectedShell).toBe(info.shell.path) + first.selectShell(alternate.path) + expect(data.size).toBe(0) + await first.start() + expect(data.get('dsh.terminal.shell')).toBe(alternate.path) + expect(h.remote.create).toHaveBeenCalledWith(sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal)) + const second = h.view() + await second.refresh() + expect(second.state.getSnapshot().selectedShell).toBe(alternate.path) + await second.dispose() + await second.start() + vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell])) + const third = h.view() + await third.refresh() + expect(third.state.getSnapshot().selectedShell).toBe(info.shell.path) + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('retains a selection across a failed launch without remembering the failed shell', async () => { + const data = storage() + const h = fixture() + vi.mocked(h.remote.create).mockResolvedValueOnce(failure('shell disappeared')) + const model = h.view() + await model.refresh() + await model.start() + expect(data.size).toBe(0) + await model.refresh() + expect(model.state.getSnapshot().selectedShell).toBe(info.shell.path) + await model.close() + await model.start() + expect(h.remote.create).toHaveBeenCalledOnce() +}) + +it('keeps launch usable when browser storage is denied and stops late shell discovery after close', async () => { + vi.stubGlobal('localStorage', undefined) + Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('denied') } }) + const h = fixture() + const model = h.view() + await model.refresh() + await model.start() + expect(model.state.getSnapshot().info).toBeDefined() + const delayed = h.view() + const shells = Promise.withResolvers>>() + vi.mocked(h.remote.shells).mockReturnValueOnce(shells.promise) + const loading = delayed.refresh() + await expect.poll(() => h.remote.shells).toHaveBeenCalledTimes(2) + await delayed.close() + shells.resolve(success([info.shell])) + await loading + expect(delayed.state.getSnapshot().phase).toBe('closed') + expect(h.remote.create).toHaveBeenCalledOnce() +}) diff --git a/packages/api/terminal-controller/tsconfig.client.json b/packages/api/terminal-controller/tsconfig.client.json index 4b78263b57..3f2a5ca277 100644 --- a/packages/api/terminal-controller/tsconfig.client.json +++ b/packages/api/terminal-controller/tsconfig.client.json @@ -8,6 +8,7 @@ "files": [ "src/client/index.ts", "src/client/model.ts", + "src/client/shell-preference.ts", "src/types.ts", "src/client/close-requests.ts" ], diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml index 769eb9afc0..8981de4d59 100644 --- a/packages/client/ui-sidebar-terminal/README.i18n.yaml +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md -README.md: 2dba3ea839f89f505286fa6b977aef3fb0e45ab0 -README.zh.md: 63d483a37fcb9b913c20e47251aaaba3e0e21ffd +README.md: f20648687bce864604a93915ea52e26990f15db4 +README.zh.md: c9f3bc8f285ff4d7dfd38104337c7466f69bca5c diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md index 2dba3ea839..f20648687b 100644 --- a/packages/client/ui-sidebar-terminal/README.md +++ b/packages/client/ui-sidebar-terminal/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Open the execution environment's default shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. +Choose an installed shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. ## Table of Contents @@ -25,7 +25,7 @@ Open the execution environment's default shell beside a conversation without lea ## Use this package -Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. The default shell starts immediately. Use the sidebar's **New tab** control to open more terminals. +Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. Choose an installed **Shell** and select **Start terminal**. The last successfully started shell is preselected from localStorage; first use or an unavailable saved choice selects the current default. Use the sidebar's **New tab** control to open more terminals. Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached. @@ -65,7 +65,7 @@ None; terminal output travels only between the browser and Host. -- The default shell or native PTY startup can fail. The tab reports the failure without launching a different shell. +- Shell discovery or native PTY startup can fail. The tab reports the failure without launching a different shell. - Completion menus and inline suggestions depend on shell configuration. The Web UI adds no independent completion engine. - Terminal history is bounded. The feature does not send terminal output to the Agent, provide split terminal panes inside a tab, or restore processes after Host restart. diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md index 63d483a37f..c9f3bc8f28 100644 --- a/packages/client/ui-sidebar-terminal/README.zh.md +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在对话旁打开执行环境的默认 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 +在对话旁选择已安装的 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。默认 shell 会立即启动。通过侧栏的**新标签页**控件打开更多终端。 +展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。选择已安装的 **Shell**,再点击**启动终端**。localStorage 会记住上次成功启动的 shell 并预选;首次使用或已保存选项不可用时,预选当前默认项。通过侧栏的**新标签页**控件打开更多终端。 双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 已退出的终端计入 Session 数量上限;达到上限时请关闭不用的标签页。 diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css index 817d30bef0..aabba1b3c1 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css @@ -7,3 +7,6 @@ .root button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } .root button:hover { background: var(--dsw-alias-bg-l2); } .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); } +.launch { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; padding: 12px; } +.shellLabel { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; } +.shellLabel select { min-width: 0; width: 100%; padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx index 9a14a55a14..af404b1e69 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx @@ -13,7 +13,7 @@ import css from './TerminalBody.module.css' export type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'sidebarTerminal'> & InjectFace /** - * Mount an automatically started or restored terminal. + * Offer shell selection for a new terminal or mount a restored screen. * @param props - sidebar occurrence, model lookup and translated copy. * @returns the terminal screen and any pending or exceptional state. */ @@ -34,6 +34,15 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable return (
+ {state.phase === 'selecting' &&
{ event.preventDefault(); void model.start() }}> + + +
} {(status !== undefined || retry || readOnly) &&
{status} {readOnly && <>{t('readonly')} } diff --git a/packages/client/ui-sidebar-terminal/src/client/locales.ts b/packages/client/ui-sidebar-terminal/src/client/locales.ts index ebda63a422..51afac8349 100644 --- a/packages/client/ui-sidebar-terminal/src/client/locales.ts +++ b/packages/client/ui-sidebar-terminal/src/client/locales.ts @@ -10,6 +10,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Simplified Chinese terminal copy. */ export const zh = { recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端', + shell: 'Shell', start: '启动终端', title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…', connecting: '正在连接…', disconnected: '连接已断开。', reconnect: '重新连接', readonly: '此页面当前只读。', control: '接管输入', @@ -26,6 +27,7 @@ export const zh = { /** English terminal copy. */ export const en = { recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery', + shell: 'Shell', start: 'Start terminal', title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…', connecting: 'Connecting…', disconnected: 'Disconnected.', reconnect: 'Reconnect', readonly: 'This view is read-only.', control: 'Take control', diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx index c20a5e7920..d8d80751be 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx @@ -65,6 +65,7 @@ function mount(initial: TerminalViewState | undefined = idle, dictionary = en) { let visible = true const detach = vi.fn() const model = { + start: vi.fn(async () => {}), selectShell: vi.fn(), mount: vi.fn(() => detach), refresh: vi.fn(async () => {}), rename: vi.fn(async () => {}), connect: vi.fn(), write: vi.fn(), resize: vi.fn(), acknowledge: vi.fn(), } @@ -309,3 +310,16 @@ it.each([en, zh])('translates known terminal failures while retaining unknown Ho h.update({ ...idle, phase: 'failed', error: 'Host permission denied' }) expect(h.view.getByRole('alert').textContent).toContain('Host permission denied') }) + +it('lets the user select an installed shell and start it before rendering a screen', () => { + const other = { path: '/bin/zsh', name: 'zsh', args: ['-i'] } + const h = mount({ phase: 'selecting', writable: false, environment, shells: [info.shell, other], selectedShell: other.path }) + expect(h.view.getByRole('combobox', { name: 'Shell' })).toHaveProperty('value', other.path) + expect(fake.terminals).toHaveLength(0) + fireEvent.change(h.view.getByRole('combobox'), { target: { value: info.shell.path } }) + expect(h.model.selectShell).toHaveBeenCalledWith(info.shell.path) + fireEvent.click(h.view.getByRole('button', { name: 'Start terminal' })) + expect(h.model.start).toHaveBeenCalledOnce() + h.update({ phase: 'selecting', writable: false, environment }) + expect(h.view.getByRole('button', { name: 'Start terminal' })).toHaveProperty('disabled', true) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 896af518c4..94eb145f2c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2519,6 +2519,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'signal', description: 'request cancellation.' }], returns: 'the Session workspace directory and terminal limits.', }, + { + signature: '@Remote shells(agent: Agent, signal: AbortSignal): Promise', + description: 'Discover installed shells in the Session\'s execution environment.', + parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'signal', description: 'request cancellation.' }], + returns: 'verified profiles, with the configured or system default first.', + }, { signature: '@Remote list(sessionId: SessionId): WebTerminalInfo[]', description: 'List retained terminals without resolving or activating an Agent.', @@ -6101,7 +6107,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TerminalCreateRequest', - declaration: 'export interface TerminalCreateRequest {\n readonly id: WebTerminalId;\n readonly cols: number;\n readonly rows: number;\n}', + declaration: 'export interface TerminalCreateRequest {\n readonly shellPath?: string;\n readonly id: WebTerminalId;\n readonly cols: number;\n readonly rows: number;\n}', }, { name: 'TerminalEnvironment', diff --git a/packages/ssh/ssh/src/helper-processes.ts b/packages/ssh/ssh/src/helper-processes.ts index e52945378b..c672a2deba 100644 --- a/packages/ssh/ssh/src/helper-processes.ts +++ b/packages/ssh/ssh/src/helper-processes.ts @@ -331,6 +331,19 @@ export class RemoteProcesses { return terminal.signalForeground(z.enum(['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP']).parse(value)) } + /** + * Resize an allocated terminal without replacing its process. + * @param id - terminal reservation. + * @param cols - positive terminal width. + * @param rows - positive terminal height. + * @returns after the local provider accepts the dimensions. + */ + async resizeTerminal(id: SshProcessId, cols: number, rows: number): Promise { + const terminal = this.record(id).terminal + if (terminal === undefined) throw new Error('SSH handle does not own a terminal') + await terminal.resize(cols, rows) + } + /** Stop every owned process on lease expiry or disconnect. */ async close(): Promise { if (this.closing) return diff --git a/packages/ssh/ssh/src/helper.ts b/packages/ssh/ssh/src/helper.ts index 61c1c82404..519d68a4a2 100644 --- a/packages/ssh/ssh/src/helper.ts +++ b/packages/ssh/ssh/src/helper.ts @@ -6,13 +6,14 @@ import type { Readable, Writable } from 'node:stream' import { Context } from '@deepseek-ai/cordis' import { FsError, type FsTarget, type FsWriteIntent, type FsVersion } from '@deepseek-ai/dsh-fs' import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox' +import { SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' import type { SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { z } from 'zod' -import { SshRpcPeer, SSH_MAX_PROCESS_HANDLES, SSH_MAX_TEXT_STREAMS, SSH_PROTOCOL_VERSION } from './protocol.ts' +import { SshRpcPeer, RemoteOperationError, SSH_MAX_PROCESS_HANDLES, SSH_MAX_TEXT_STREAMS, SSH_PROTOCOL_VERSION } from './protocol.ts' import { RemoteProcesses } from './helper-processes.ts' import { editSchema, environmentSchema, intentSchema, policySchema, processIdSchema, remotePath, targetSchema, textStreamIdSchema } from './schemas.ts' import type { SshTextStreamId } from './schemas.ts' @@ -114,6 +115,14 @@ export async function runSshHelper(transport: HelperTransport): Promise { if (method === 'process.done') return processes.done(processIdRequest.parse(raw).id) if (method === 'process.wait') return processes.wait(processIdRequest.parse(raw).id, signal) if (method === 'process.terminate') { await processes.terminate(processIdRequest.parse(raw).id); return null } + if (method === 'terminal.environment') { object.parse(raw); return ctx.subprocess.terminalEnvironment(signal) } + if (method === 'terminal.resize') { + const input = z.object({ + id: processIdSchema, cols: z.number().int().positive(), rows: z.number().int().positive(), + }).strict().parse(raw) + await processes.resizeTerminal(input.id, input.cols, input.rows) + return null + } if (method === 'terminal.write' || method === 'terminal.inspect' || method === 'terminal.signal') { const input = z.object({ id: processIdSchema, value: z.string().optional() }).strict().parse(raw) return processes.terminal(input.id, method === 'terminal.write' ? 'write' : method === 'terminal.inspect' ? 'inspect' : 'signal', input.value) @@ -123,7 +132,12 @@ export async function runSshHelper(transport: HelperTransport): Promise { const env = input.env === undefined ? undefined : Object.fromEntries( Object.entries(input.env).filter((entry): entry is [string, string] => entry[1] !== null), ) - return ctx.subprocess.resolveExecutable(input.command, env, signal) + try { + return await ctx.subprocess.resolveExecutable(input.command, env, signal) + } catch (error) { + if (error instanceof SubprocessExecutableNotFoundError) throw new RemoteOperationError(error.message, 'SUBPROCESS_EXECUTABLE_NOT_FOUND') + throw error + } } if (method === 'sandbox') { const input = z.object({ argv: z.array(z.string()).min(1), policy: policySchema }).strict().parse(raw) diff --git a/packages/ssh/ssh/src/schemas.ts b/packages/ssh/ssh/src/schemas.ts index 23b754067e..1b7db7dc92 100644 --- a/packages/ssh/ssh/src/schemas.ts +++ b/packages/ssh/ssh/src/schemas.ts @@ -48,7 +48,9 @@ export const spawnSchema = z.object({ stderr: z.union([z.literal('pipe'), z.literal('inherit'), collection]), control: z.literal('pipe').optional(), }).strict().optional(), - terminal: z.object({ rows: z.number().int().positive(), cols: z.number().int().positive() }).strict().optional(), + terminal: z.object({ + terminalType: z.string().min(1), rows: z.number().int().positive(), cols: z.number().int().positive(), + }).strict().optional(), }).strict().refine(value => (value.stdio === undefined) !== (value.terminal === undefined), 'select ordinary or terminal execution') /** Connection handshake binds sockets and workspace to one helper process. */ export const helloSchema = z.object({ protocol: z.literal(1), hash: z.string().regex(/^[0-9a-f]{64}$/), platform: z.enum(['linux', 'darwin']), nodeVersion: z.string(), node: remotePath, root: remotePath, workspace: remotePath, bootstrapHash: z.string().regex(/^[0-9a-f]{64}$/).optional() }).strict() diff --git a/packages/ssh/ssh/tests/helper-processes.spec.ts b/packages/ssh/ssh/tests/helper-processes.spec.ts index a7b42bd9d8..c94ffb7c8a 100644 --- a/packages/ssh/ssh/tests/helper-processes.spec.ts +++ b/packages/ssh/ssh/tests/helper-processes.spec.ts @@ -19,7 +19,7 @@ vi.mock('node:fs/promises', async (original) => { const outcome = { exitCode: 0, signal: null } const ordinaryRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' } } -const terminalRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, terminal: { rows: 24, cols: 80 } } +const terminalRequest = { argv: ['target'], cwd: '/tmp', graceMs: 20, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } } async function harness() { const root = await mkdtemp('/tmp/dsh-ssh-owner-') @@ -76,10 +76,12 @@ function terminal() { const inspectForeground = vi.fn(async () => undefined) const signalForeground = vi.fn(async () => 42) const terminate = vi.fn(async () => { output.end(); completion.resolve(outcome) }) + const resize = vi.fn(async (_cols: number, _rows: number) => {}) const handle: SubprocessTerminalHandle = { + resize, pid: 42, output, done: completion.promise, write, inspectForeground, signalForeground, terminate, } - return { handle, completion, output, write, inspectForeground, signalForeground, terminate } + return { handle, completion, output, write, resize, inspectForeground, signalForeground, terminate } } describe.skipIf(process.platform === 'win32')('SSH helper process settlement', () => { @@ -99,6 +101,8 @@ describe.skipIf(process.platform === 'win32')('SSH helper process settlement', ( await expect(test.owner.start(run.id)).rejects.toThrow('already requested') await test.owner.terminal(run.id, 'write', 'input') expect(child.write).toHaveBeenCalledWith('input') + await test.owner.resizeTerminal(run.id, 120, 40) + expect(child.resize).toHaveBeenCalledWith(120, 40) expect(await test.owner.terminal(run.id, 'inspect')).toBeNull() child.inspectForeground.mockResolvedValueOnce({ processGroupId: 42, inputWaiting: true }) expect(await test.owner.terminal(run.id, 'inspect')).toEqual({ processGroupId: 42, inputWaiting: true }) @@ -144,6 +148,7 @@ describe.skipIf(process.platform === 'win32')('SSH helper process settlement', ( const run = await test.prepare({ ...ordinaryRequest, env: { VALUE: 'kept', REMOVED: null }, stdio: { ...ordinaryRequest.stdio, stdin: 'pipe' } }) await expect(test.owner.done(run.id)).rejects.toThrow('has not started') await expect(test.owner.wait(run.id)).rejects.toThrow('was not started') + await expect(test.owner.resizeTerminal(run.id, 80, 24)).rejects.toThrow('does not own a terminal') const bytes: Buffer[] = [] child.stdin.on('data', (chunk: Buffer) => { bytes.push(Buffer.from(chunk)) }) const ended = once(child.stdin, 'end') diff --git a/packages/ssh/ssh/tests/helper-runtime.spec.ts b/packages/ssh/ssh/tests/helper-runtime.spec.ts index 1e4aa21070..3336986a8e 100644 --- a/packages/ssh/ssh/tests/helper-runtime.spec.ts +++ b/packages/ssh/ssh/tests/helper-runtime.spec.ts @@ -93,6 +93,9 @@ describe.skipIf(process.platform === 'win32')('SSH helper runtime', () => { await expect(test.client.request('sandbox', { argv: ['true'], policy: { mode: 'danger-full-access', workspaceRoot: test.root } }, z.unknown())).rejects.toThrow('does not need') expect(await test.client.request('executable', { command: process.execPath, env: { REMOVED: null } }, z.string())).toBe(process.execPath) expect(await test.client.request('executable', { command: process.execPath }, z.string())).toBe(process.execPath) + expect(await test.client.request('terminal.environment', {}, z.unknown())).toMatchObject({ platform: 'posix' }) + await expect(test.client.request('executable', { command: `${test.root}/absent` }, z.string())).rejects.toMatchObject({ code: 'SUBPROCESS_EXECUTABLE_NOT_FOUND' }) + await expect(test.client.request('executable', { command: './relative' }, z.string())).rejects.toThrow('relative') const wrapped = await test.client.request('sandbox', { argv: ['true'], policy: policy(test.root) }, z.looseObject({ argv: z.array(z.string()), enforcement: z.enum(['full', 'partial']) })) expect(wrapped.argv).toEqual(['confined', 'true']) expect(confine).toHaveBeenCalledWith(['true'], policy(test.root), expect.any(AbortSignal)) diff --git a/packages/ssh/ssh/tests/live.e2e.ts b/packages/ssh/ssh/tests/live.e2e.ts index 045ea794d6..6fd71de3c5 100644 --- a/packages/ssh/ssh/tests/live.e2e.ts +++ b/packages/ssh/ssh/tests/live.e2e.ts @@ -191,7 +191,7 @@ describe.skipIf(!enabled)('POSIX SSH runtime acceptance', () => { const test = await setup() try { const terminal = await test.ctx.subprocess.spawnTerminal({ - argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd: test.root, rows: 24, cols: 80, graceMs: 500, + terminalType: 'dumb', argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd: test.root, rows: 24, cols: 80, graceMs: 500, }) const output: string[] = [] const received = Promise.withResolvers() diff --git a/packages/ssh/ssh/tests/stream-capability.spec.ts b/packages/ssh/ssh/tests/stream-capability.spec.ts index fc1ca16187..9729a9f50d 100644 --- a/packages/ssh/ssh/tests/stream-capability.spec.ts +++ b/packages/ssh/ssh/tests/stream-capability.spec.ts @@ -21,7 +21,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream capabilities', () => { const owner = new RemoteProcesses(new Context(), root, 1, 5000) let stream: Socket | undefined try { - const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } }) + const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } }) const endpoint = prepared.streams.terminal! const raw = createConnection(endpoint.path) await once(raw, 'connect') @@ -40,7 +40,7 @@ describe.skipIf(process.platform === 'win32')('SSH stream capabilities', () => { const owner = new RemoteProcesses(new Context(), root, 1, 5000) const sockets: Socket[] = [] try { - const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } }) + const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } }) const endpoint = prepared.streams.terminal! const raw = [createConnection(endpoint.path), createConnection(endpoint.path)] await Promise.all(raw.map(socket => once(socket, 'connect'))) diff --git a/packages/ssh/ssh/tests/stream-lifetime.spec.ts b/packages/ssh/ssh/tests/stream-lifetime.spec.ts index 9f88a2224e..2feeb1d52e 100644 --- a/packages/ssh/ssh/tests/stream-lifetime.spec.ts +++ b/packages/ssh/ssh/tests/stream-lifetime.spec.ts @@ -12,7 +12,7 @@ describe('SSH TLS cancellation ownership', () => { const root = await mkdtemp('/tmp/dsh-ssh-tls-life-') const owner = new RemoteProcesses(new Context(), root, 1, 5000) try { - const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { rows: 24, cols: 80 } }) + const prepared = await owner.prepare({ argv: ['true'], cwd: root, graceMs: 100, terminal: { terminalType: 'dumb', rows: 24, cols: 80 } }) const endpoint = prepared.streams.terminal! const raw = createConnection({ path: endpoint.path, allowHalfOpen: true }) raw.on('error', () => {}) diff --git a/packages/ssh/subprocess-ssh/README.i18n.yaml b/packages/ssh/subprocess-ssh/README.i18n.yaml index df7872748e..4b47b611a5 100644 --- a/packages/ssh/subprocess-ssh/README.i18n.yaml +++ b/packages/ssh/subprocess-ssh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ssh/subprocess-ssh/README.md -README.md: ea647cd15b97ae281e145c5ac3afbf1e4cc4740c -README.zh.md: 455da9cade52540eb69581ddc3ec858553c444a5 +README.md: 15afa74f041e9944036c451dacaaedb53ecd9ff9 +README.zh.md: 3417f184fbcb702edeadf3b510663b1a9a58192f diff --git a/packages/ssh/subprocess-ssh/README.md b/packages/ssh/subprocess-ssh/README.md index ea647cd15b..15afa74f04 100644 --- a/packages/ssh/subprocess-ssh/README.md +++ b/packages/ssh/subprocess-ssh/README.md @@ -33,6 +33,8 @@ A direct exit reported by `done` does not prove managed-range quiescence; `waitF ----- +Terminal shell facts and executable verification come from the remote helper. Lookup misses remain distinct from SSH failures. PTY creation forwards the caller's `terminalType`, and `resize()` updates the remote terminal without replacing its process. + ## Understand the implementation diff --git a/packages/ssh/subprocess-ssh/README.zh.md b/packages/ssh/subprocess-ssh/README.zh.md index 455da9cade..3417f184fb 100644 --- a/packages/ssh/subprocess-ssh/README.zh.md +++ b/packages/ssh/subprocess-ssh/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" ----- +终端 shell 信息与可执行文件验证来自远端 helper。查找未命中与 SSH 故障分别报告。PTY 创建传递调用方的 `terminalType`,`resize()` 更新远端终端尺寸,不替换进程。 + ## 理解实现 diff --git a/packages/ssh/subprocess-ssh/src/index.ts b/packages/ssh/subprocess-ssh/src/index.ts index da69932102..ea2ba64907 100644 --- a/packages/ssh/subprocess-ssh/src/index.ts +++ b/packages/ssh/subprocess-ssh/src/index.ts @@ -2,13 +2,13 @@ import { Duplex, PassThrough, type Readable, type Writable } from 'node:stream' import type { Socket } from 'node:net' import { Context } from '@deepseek-ai/cordis' -import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' -import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalEnvironment, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output' import type { SshConnection } from '@deepseek-ai/dsh-ssh' import { doneSchema, foregroundSchema, outputSnapshotFrameLimit, outputSnapshotSchema, preparedSchema, remotePath, streamEndpointSchema } from '@deepseek-ai/dsh-ssh/schemas' import type { SshProcessId } from '@deepseek-ai/dsh-ssh/schemas' -import { SshRpcPeer } from '@deepseek-ai/dsh-ssh/protocol' +import { SshRpcPeer, RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol' import { z } from 'zod' function environment(env?: NodeJS.ProcessEnv): Record | undefined { @@ -252,8 +252,23 @@ export class SshSubprocessRuntime extends SubprocessRuntime { }) } - override resolveExecutable(command: string, env?: Readonly>, signal?: AbortSignal): Promise { - return this.ctx.ssh.request('executable', { command, env }, remotePath, signal) + override async resolveExecutable(command: string, env?: Readonly>, signal?: AbortSignal): Promise { + try { + return await this.ctx.ssh.request('executable', { command, env }, remotePath, signal) + } catch (error) { + if (error instanceof RemoteOperationError && error.code === 'SUBPROCESS_EXECUTABLE_NOT_FOUND') { + throw new SubprocessExecutableNotFoundError(error.message, { cause: error }) + } + throw error + } + } + + override terminalEnvironment(signal?: AbortSignal): Promise { + return this.ctx.ssh.request('terminal.environment', {}, z.object({ + platform: z.enum(['posix', 'windows']), defaultShell: z.string().optional(), + }).strict().transform(value => ({ platform: value.platform, + ...(value.defaultShell === undefined ? {} : { defaultShell: value.defaultShell }), + })), signal) } override spawn(spec: SubprocessSpawnSpec): SubprocessHandle { @@ -279,7 +294,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime { const ssh = this.ctx.ssh const prepared = await ssh.request('process.prepare', { argv: spec.argv, cwd: spec.cwd, env: environment(spec.env), graceMs: spec.graceMs, - terminal: { rows: spec.rows, cols: spec.cols }, + terminal: { rows: spec.rows, cols: spec.cols, terminalType: spec.terminalType }, }, preparedSchema, signal) const id = prepared.id let socket: Socket | undefined @@ -298,6 +313,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime { const abort = (): void => { void handle.terminate().catch(() => { void ssh.dispose().catch(() => {}) }) } const handle: SubprocessTerminalHandle = { pid: started.pid, output, done, + resize: async (cols, rows) => { await ssh.request('terminal.resize', { id, cols, rows }, z.null()) }, write: async (data) => { await ssh.request('terminal.write', { id, value: data }, z.null()) }, inspectForeground: async () => await ssh.request('terminal.inspect', { id }, foregroundSchema) ?? undefined, signalForeground: (signal: SubprocessTerminalSignal) => ssh.request('terminal.signal', { id, value: signal }, z.number().int().positive()), diff --git a/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts b/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts index 8f62ee1cea..4444a7e5eb 100644 --- a/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts +++ b/packages/ssh/subprocess-ssh/tests/lifecycle.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { SshSubprocessRuntime } from '../src/index.ts' const id = 'a7b17d9c-5ebf-40d8-9e82-3d1ddae8517b' -const spec = { argv: ['bash'], cwd: '/workspace', rows: 24, cols: 80, graceMs: 100 } +const spec = { terminalType: 'dumb', argv: ['bash'], cwd: '/workspace', rows: 24, cols: 80, graceMs: 100 } describe('SSH terminal allocation lifecycle', () => { it.each(['prepare', 'connect', 'start'])('joins a pending %s before completing provider disposal', async (stage) => { diff --git a/packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts b/packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts new file mode 100644 index 0000000000..7af2872b28 --- /dev/null +++ b/packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts @@ -0,0 +1,58 @@ +/** Shell facts and lookup failures retain remote execution semantics. */ +import { Context } from '@deepseek-ai/cordis' +import { SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess' +import { RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol' +import { expect, it, vi } from 'vitest' +import { z } from 'zod' +import { SshSubprocessRuntime } from '../src/index.ts' + +it('validates remote shell facts and distinguishes a lookup miss from transport failure', async () => { + const ctx = new Context() + let facts: unknown = { platform: 'posix', defaultShell: '/remote/bin/zsh' } + let failure: Error | undefined + const request = vi.fn(async (method: string, _params: unknown, schema: z.ZodType): Promise => { + if (failure !== undefined) throw failure + return schema.parse(method === 'terminal.environment' ? facts : '/remote/bin/bash') + }) + ctx.provide('ssh', { request } as never) + const runtime = new SshSubprocessRuntime(ctx) + const signal = new AbortController().signal + try { + expect(await runtime.terminalEnvironment(signal)).toEqual(facts) + expect(request).toHaveBeenCalledWith('terminal.environment', {}, expect.anything(), signal) + facts = { platform: 'posix' } + expect(await runtime.terminalEnvironment()).toEqual(facts) + facts = { platform: 'untrusted' } + await expect(runtime.terminalEnvironment()).rejects.toBeInstanceOf(z.ZodError) + expect(await runtime.resolveExecutable('bash', undefined, signal)).toBe('/remote/bin/bash') + failure = new RemoteOperationError('not installed', 'SUBPROCESS_EXECUTABLE_NOT_FOUND') + await expect(runtime.resolveExecutable('absent')).rejects.toBeInstanceOf(SubprocessExecutableNotFoundError) + failure = new RemoteOperationError('permission', 'EACCES') + await expect(runtime.resolveExecutable('bash')).rejects.toBe(failure) + failure = new Error('SSH disconnected') + await expect(runtime.resolveExecutable('bash')).rejects.toBe(failure) + } finally { await ctx.fiber.dispose() } +}) + +it.skipIf(process.platform === 'win32')('queries the real helper and resizes its PTY without replacing the shell', async () => { + const { createHelperHarness } = await import('../../ssh/tests/fixtures/helper.ts') + const helper = await createHelperHarness() + const ctx = new Context() + ctx.provide('ssh', helper.connection as never) + const runtime = new SshSubprocessRuntime(ctx) + try { + expect(await runtime.terminalEnvironment()).toMatchObject({ platform: 'posix' }) + await expect(runtime.resolveExecutable(`${helper.root}/absent`)).rejects.toBeInstanceOf(SubprocessExecutableNotFoundError) + const terminal = await runtime.spawnTerminal({ argv: ['/bin/sh', '-i'], cwd: helper.root, cols: 80, rows: 24, terminalType: 'xterm-256color', graceMs: 100 }) + let output = '' + terminal.output.on('data', (chunk: Buffer) => { output += chunk.toString() }) + await terminal.resize(120, 40) + await terminal.write('printf "REMOTE_TERM:%s\\n" "$TERM"; stty size\n') + await expect.poll(() => output).toContain('REMOTE_TERM:xterm-256color') + await expect.poll(() => output).toMatch(/40\s+120/u) + await terminal.terminate() + await terminal.done + } finally { + try { await ctx.fiber.dispose() } finally { await helper.close() } + } +}) diff --git a/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts b/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts index b29902f6e4..7ec4e4b15c 100644 --- a/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts +++ b/packages/ssh/subprocess-ssh/tests/terminal-behavior.spec.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import { SshSubprocessRuntime } from '../src/index.ts' const id = 'aa910b47-e7d7-467b-8421-3331569dd02b' -const spec: SubprocessTerminalSpawnSpec = { argv: ['bash'], cwd: '/remote/workspace', rows: 24, cols: 80, graceMs: 100 } +const spec: SubprocessTerminalSpawnSpec = { argv: ['bash'], cwd: '/remote/workspace', terminalType: 'dumb', rows: 24, cols: 80, graceMs: 100 } const completion = { outcome: { exitCode: 0, signal: null }, spills: {}, collected: {} } async function setup(options: { @@ -42,7 +42,7 @@ async function setup(options: { if (options.terminateFailure !== undefined) throw options.terminateFailure finished.resolve(completion) value = null - } else if (method === 'terminal.write') value = null + } else if (method === 'terminal.write' || method === 'terminal.resize') value = null else if (method === 'terminal.inspect') value = foreground else if (method === 'terminal.signal') value = 321 else throw new Error(`Unexpected terminal request ${method}`) @@ -89,13 +89,15 @@ describe('SSH terminal behavior', () => { test.remote.write('terminal output') expect(String((await data)[0])).toBe('terminal output') await handle.write('input\n') + await handle.resize(120, 40) + expect(test.calls.find(call => call.method === 'terminal.resize')?.params).toEqual({ id, cols: 120, rows: 40 }) expect(await handle.inspectForeground()).toBeUndefined() test.setForeground({ processGroupId: 321, inputWaiting: true }) expect(await handle.inspectForeground()).toEqual({ processGroupId: 321, inputWaiting: true }) expect(await handle.signalForeground('SIGINT')).toBe(321) expect(test.calls.find(call => call.method === 'process.prepare')?.params).toEqual({ argv: ['bash'], cwd: spec.cwd, env: { KEEP: 'value' }, graceMs: 100, - terminal: { rows: 24, cols: 80 }, + terminal: { terminalType: 'dumb', rows: 24, cols: 80 }, }) expect(test.calls.find(call => call.method === 'terminal.write')?.params).toEqual({ id, value: 'input\n' }) expect(test.calls.find(call => call.method === 'terminal.signal')?.params).toEqual({ id, value: 'SIGINT' }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e1566714c2..88b7f478a6 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -787,6 +787,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { WorkspaceFileRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileStat: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileText: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', + TerminalShell: 'Browser terminal shell profiles are owned by packages/api/terminal-controller/README.md', TerminalEnvironment: 'Browser terminal environment fields are owned by packages/api/terminal-controller/README.md', WebTerminalInfo: 'Browser terminal metadata is owned by packages/api/terminal-controller/README.md', TerminalCreateRequest: 'Browser terminal allocation fields are owned by packages/api/terminal-controller/README.md', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index fa1db54c75..835c252675 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -54,6 +54,7 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = { /** Runtime exports that require every consumer to resolve the provider's shared peer instance. */ const PEER_REQUIRED_HOST_EXPORTS = { + '@deepseek-ai/dsh-subprocess': ['SubprocessExecutableNotFoundError'], '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'], '@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'], '@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'], diff --git a/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl index 0c58c12c6e..a184b87d82 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl @@ -19,7 +19,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly control: Duplex | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n control?: 'pipe';\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"inspect-subprocess-api"},"content":[{"type":"tool-result","toolCallId":"inspect-subprocess-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"subprocess\",\n \"description\": \"Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).\\n\\nImplementations must honor these semantics:\\n\\n- Executable paths belong to one execution world shared with the mounted filesystem provider.\\n- spawn returns a live handle synchronously. Target identity remains provider-private; `done` resolves with the spawned command's exit facts and may reject for spawn or provider failures.\\n- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here.\\n- SubprocessHandle.terminate (and the spec's abort signal) starts the provider's documented procedure against its managed range. SubprocessHandle.waitForExit observes that same range so a consumer-owned teardown ladder can hold each tier on real quiescence; each provider documents its signalling and observability limits.\\n- Disposal of the service terminates all still-running managed processes and awaits their exit.\\n- spawnTerminal owns terminal allocation, text transport, foreground groups, signalling, and whole-session quiescence behind one awaited termination method; readiness and persistent-shell policy stay in the PTY consumer. Its output stream ends after queued terminal output when the top-level process exits.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"subprocess\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"subprocess\"\n ],\n \"expression\": \"ctx.subprocess\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"abstract resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise\",\n \"description\": \"Resolve one configured executable in this provider's execution world. Absolute paths are verified; bare names use the provider's scrubbed PATH plus explicit environment overrides. Relative paths containing separators are rejected: the resolution base is undefined, so providers fail loud instead of guessing.\",\n \"parameters\": [\n {\n \"name\": \"command\",\n \"description\": \"absolute executable path or bare PATH name.\"\n },\n {\n \"name\": \"env\",\n \"description\": \"explicit environment entries used for lookup.\"\n },\n {\n \"name\": \"signal\",\n \"description\": \"aborts remote or local lookup.\"\n }\n ],\n \"returns\": \"a canonical executable path.\"\n },\n {\n \"signature\": \"abstract terminalEnvironment(signal?: AbortSignal): Promise\",\n \"description\": \"Inspect shell-selection facts in the provider's execution environment.\",\n \"parameters\": [\n {\n \"name\": \"signal\",\n \"description\": \"cancellation of remote environment inspection.\"\n }\n ],\n \"returns\": \"platform and preferred shell; executable lookup and allocation remain separate operations.\"\n },\n {\n \"signature\": \"abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle\",\n \"description\": \"Start one managed child process from a fully-specified spec; this seam applies no defaults.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"argv, directory, stdio dispositions, grace, cancellation, and environment.\"\n }\n ],\n \"returns\": \"the live process handle (streams/readers, signalling, outcome promise).\",\n \"throws\": [\n \"synchronously when pre-aborted or when argv, cwd, environment, or grace is invalid before handle creation.\"\n ]\n },\n {\n \"signature\": \"abstract spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise\",\n \"description\": \"Allocate a real terminal and start one owned process session. This is the only non-pipe process primitive: implementations own terminal byte I/O, foreground groups, signals, and whole-session quiescence.\",\n \"parameters\": [\n {\n \"name\": \"spec\",\n \"description\": \"fully specified argv, cwd, environment, dimensions, grace, and allocation cancellation.\"\n }\n ],\n \"returns\": \"the live terminal handle after allocation succeeds.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"SubprocessCollect\",\n \"declaration\": \"export interface SubprocessCollect {\\n maxBytes: number;\\n spill?: {\\n maxBytes: number;\\n };\\n}\"\n },\n {\n \"name\": \"SubprocessCollectedOutputs\",\n \"declaration\": \"export interface SubprocessCollectedOutputs {\\n readonly stdout?: SubprocessOutputReader;\\n readonly stderr?: SubprocessOutputReader;\\n}\"\n },\n {\n \"name\": \"SubprocessHandle\",\n \"declaration\": \"export interface SubprocessHandle {\\n readonly stdin: Writable | undefined;\\n readonly stdout: Readable | undefined;\\n readonly stderr: Readable | undefined;\\n readonly control: Duplex | undefined;\\n readonly collected: SubprocessCollectedOutputs;\\n readonly done: Promise;\\n terminate(): void;\\n waitForExit(signal?: AbortSignal): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessOutcome\",\n \"declaration\": \"export interface SubprocessOutcome {\\n exitCode: number | null;\\n signal: NodeJS.Signals | null;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputMode\",\n \"declaration\": \"export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect;\"\n },\n {\n \"name\": \"SubprocessOutputRead\",\n \"declaration\": \"export interface SubprocessOutputRead {\\n text: string;\\n nextOffset: number;\\n lossy: boolean;\\n spillPath?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessOutputReader\",\n \"declaration\": \"export interface SubprocessOutputReader {\\n readFrom(fromByte: number): SubprocessOutputRead;\\n}\"\n },\n {\n \"name\": \"SubprocessSpawnSpec\",\n \"declaration\": \"export interface SubprocessSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n stdio: SubprocessStdio;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n env?: NodeJS.ProcessEnv | undefined;\\n}\"\n },\n {\n \"name\": \"SubprocessStdinMode\",\n \"declaration\": \"export type SubprocessStdinMode = 'ignore' | 'pipe' | {\\n readonly data: string;\\n};\"\n },\n {\n \"name\": \"SubprocessStdio\",\n \"declaration\": \"export interface SubprocessStdio {\\n stdin: SubprocessStdinMode;\\n stdout: SubprocessOutputMode;\\n stderr: SubprocessOutputMode;\\n control?: 'pipe';\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalEnvironment\",\n \"declaration\": \"export interface SubprocessTerminalEnvironment {\\n platform: 'posix' | 'windows';\\n defaultShell?: string;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalForeground\",\n \"declaration\": \"export interface SubprocessTerminalForeground {\\n processGroupId: number;\\n inputWaiting: boolean;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalHandle\",\n \"declaration\": \"export interface SubprocessTerminalHandle {\\n readonly pid: number;\\n readonly output: Readable;\\n readonly done: Promise;\\n write(data: string): Promise;\\n resize(cols: number, rows: number): Promise;\\n inspectForeground(): Promise;\\n signalForeground(signal: SubprocessTerminalSignal): Promise;\\n terminate(): Promise;\\n}\"\n },\n {\n \"name\": \"SubprocessTerminalSignal\",\n \"declaration\": \"export type SubprocessTerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGKILL' | 'SIGTSTP' | 'SIGHUP';\"\n },\n {\n \"name\": \"SubprocessTerminalSpawnSpec\",\n \"declaration\": \"export interface SubprocessTerminalSpawnSpec {\\n argv: readonly string[];\\n cwd: string;\\n env?: Record | undefined;\\n rows: number;\\n cols: number;\\n terminalType: string;\\n graceMs: number;\\n signal?: AbortSignal | undefined;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:7}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146037,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1788778146037,"index":0,"dt":[],"texts":["CORDIS_INSPECT_JSDOC_OK"]},{"type":"chunk","time":1788778146037,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146037,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From 37e5d8c4aac1ac5c97c9ac61382faf9c17568f63 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 14 Sep 2026 11:02:53 +0800 Subject: [PATCH 30/38] fix(web): remember shell choices before terminal startup --- .../2026-09-09-web-sidebar-terminal.i18n.yaml | 4 ++-- .../feature/2026-09-09-web-sidebar-terminal.md | 2 +- .../feature/2026-09-09-web-sidebar-terminal.zh.md | 2 +- apps/web/tests/sidebar-terminal.e2e.ts | 1 + packages/api/terminal-controller/README.i18n.yaml | 4 ++-- packages/api/terminal-controller/README.md | 2 +- packages/api/terminal-controller/README.zh.md | 2 +- packages/api/terminal-controller/src/client/model.ts | 9 ++++++--- .../src/client/shell-preference.ts | 6 +++--- .../tests/recovery.client.spec.ts | 12 ++++++++---- packages/client/ui-sidebar-terminal/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar-terminal/README.md | 2 +- packages/client/ui-sidebar-terminal/README.zh.md | 2 +- 13 files changed, 30 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index 2a4d90b89c..21ad4429b4 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: f922e8c889fea0fef1fafcd729b1a5489a8b0c42 -2026-09-09-web-sidebar-terminal.zh.md: e53c71e3f6c307e1afe2fc9f1f9561599ba41829 +2026-09-09-web-sidebar-terminal.md: ce1162a84d3f96336cbb217dbdfe62d12ac23280 +2026-09-09-web-sidebar-terminal.zh.md: 51e0f374b5fde389323868e56fbd86d3bc5720b3 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index f922e8c889..ce1162a84d 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -10,7 +10,7 @@ Web users need an interactive shell beside a Session to inspect the workspace an ## Decision -`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last successful shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. +`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions. Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index e53c71e3f6..51e0f374b5 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -10,7 +10,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 ## 决定 -`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次成功启动的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 +`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。 diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index 072801c58d..4a721047e4 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -169,6 +169,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)), await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode()) await selector.selectOption('/bin/sh') + expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh') await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true }) await page.getByRole('button', { name: 'Start terminal', exact: true }).click() await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"") diff --git a/packages/api/terminal-controller/README.i18n.yaml b/packages/api/terminal-controller/README.i18n.yaml index 618b771218..0a1f8343a9 100644 --- a/packages/api/terminal-controller/README.i18n.yaml +++ b/packages/api/terminal-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md -README.md: a24cb6e572782b59b83b836f0f477358abadf347 -README.zh.md: b979a7913e3b7fbcd1330ed7459eea9423348941 +README.md: c302a2b7269bdb6a76d0c6056d5851a84a876e9a +README.zh.md: dfc50e30f3a35b70c40bb76f9832cbd9548ba2cf diff --git a/packages/api/terminal-controller/README.md b/packages/api/terminal-controller/README.md index a24cb6e572..c302a2b726 100644 --- a/packages/api/terminal-controller/README.md +++ b/packages/api/terminal-controller/README.md @@ -52,7 +52,7 @@ The latest attachment owns input and resize. Detachment releases input control w Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes. -New views wait for explicit shell selection and start. The last successful shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. A saved path is preselected only while Host discovery still lists it; otherwise the current default is selected. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells. +New views wait for explicit shell selection and start. The last selected shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. A saved path is preselected only while Host discovery still lists it; otherwise the current default is selected. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells. Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload. diff --git a/packages/api/terminal-controller/README.zh.md b/packages/api/terminal-controller/README.zh.md index b979a7913e..dfc50e30f3 100644 --- a/packages/api/terminal-controller/README.zh.md +++ b/packages/api/terminal-controller/README.zh.md @@ -52,7 +52,7 @@ Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终 Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。 -新视图等待用户选择 shell 并启动。上次成功启动的 shell 路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。仅当 Host 探测仍列出该路径时才预选它,否则选择当前默认项。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。 +新视图等待用户选择 shell 并启动。上次选择的 shell 路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。仅当 Host 探测仍列出该路径时才预选它,否则选择当前默认项。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。 关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。 diff --git a/packages/api/terminal-controller/src/client/model.ts b/packages/api/terminal-controller/src/client/model.ts index 1150718f1f..f6c1a350ad 100644 --- a/packages/api/terminal-controller/src/client/model.ts +++ b/packages/api/terminal-controller/src/client/model.ts @@ -125,12 +125,15 @@ export class TerminalView { } /** - * Select one verified shell before starting the terminal. + * Select and remember one verified shell before starting the terminal. * @param path - executable path offered by Host discovery. */ selectShell(path: string): void { const state = this.state.getSnapshot() - if (state.phase === 'selecting' && state.shells?.some(shell => shell.path === path)) this.patch({ selectedShell: path }) + if (state.phase === 'selecting' && state.shells?.some(shell => shell.path === path)) { + this.patch({ selectedShell: path }) + rememberShell(path) + } } /** @@ -142,6 +145,7 @@ export class TerminalView { const state = this.state.getSnapshot() if (state.phase !== 'selecting' || state.environment === undefined || state.selectedShell === undefined || this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve() + rememberShell(state.selectedShell) return this.create(state.environment, state.selectedShell) } @@ -154,7 +158,6 @@ export class TerminalView { id: this.id, shellPath, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows), }, this.lifetime.signal)) if (!this.lifetime.signal.aborted) { - rememberShell(info.shell.path) this.adopt(info) } })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.creation = undefined }) diff --git a/packages/api/terminal-controller/src/client/shell-preference.ts b/packages/api/terminal-controller/src/client/shell-preference.ts index 044a41d845..8e8a461591 100644 --- a/packages/api/terminal-controller/src/client/shell-preference.ts +++ b/packages/api/terminal-controller/src/client/shell-preference.ts @@ -3,7 +3,7 @@ const KEY = 'dsh.terminal.shell' /** * Read the browser preference. - * @returns the last successfully started shell path, or null when storage is unavailable. + * @returns the last selected shell path, or null when storage is unavailable. */ export function preferredShell(): string | null { try { return typeof localStorage === 'undefined' ? null : localStorage.getItem(KEY) } @@ -11,8 +11,8 @@ export function preferredShell(): string | null { } /** - * Remember a successful shell launch without making storage a startup dependency. - * @param path - verified executable path returned by the Host. + * Remember the selected shell without making storage a startup dependency. + * @param path - verified executable path offered by the Host. */ export function rememberShell(path: string): void { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, path) } diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts index d58a9e26ba..3839e2024a 100644 --- a/packages/api/terminal-controller/tests/recovery.client.spec.ts +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -445,7 +445,7 @@ it('waits for both active and detached stream finalizers during plugin disposal expect(h.remote.close).not.toHaveBeenCalled() }) -it('remembers a successful shell choice for new views and falls back when that shell is absent', async () => { +it('remembers a shell choice immediately for new views and falls back when that shell is absent', async () => { const data = storage() const h = fixture() const alternate = { name: 'bash', path: '/bin/bash', args: ['-i'] } @@ -459,7 +459,11 @@ it('remembers a successful shell choice for new views and falls back when that s first.selectShell('/not-listed') expect(first.state.getSnapshot().selectedShell).toBe(info.shell.path) first.selectShell(alternate.path) - expect(data.size).toBe(0) + expect(data.get('dsh.terminal.shell')).toBe(alternate.path) + const beforeLaunch = h.view() + await beforeLaunch.refresh() + expect(beforeLaunch.state.getSnapshot().selectedShell).toBe(alternate.path) + expect(h.remote.create).not.toHaveBeenCalled() await first.start() expect(data.get('dsh.terminal.shell')).toBe(alternate.path) expect(h.remote.create).toHaveBeenCalledWith(sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal)) @@ -475,14 +479,14 @@ it('remembers a successful shell choice for new views and falls back when that s expect(h.remote.create).toHaveBeenCalledOnce() }) -it('retains a selection across a failed launch without remembering the failed shell', async () => { +it('retains the last selection across a failed launch', async () => { const data = storage() const h = fixture() vi.mocked(h.remote.create).mockResolvedValueOnce(failure('shell disappeared')) const model = h.view() await model.refresh() await model.start() - expect(data.size).toBe(0) + expect(data.get('dsh.terminal.shell')).toBe(info.shell.path) await model.refresh() expect(model.state.getSnapshot().selectedShell).toBe(info.shell.path) await model.close() diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml index 8981de4d59..4a97230d15 100644 --- a/packages/client/ui-sidebar-terminal/README.i18n.yaml +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md -README.md: f20648687bce864604a93915ea52e26990f15db4 -README.zh.md: c9f3bc8f285ff4d7dfd38104337c7466f69bca5c +README.md: d36e42ccea3a4b419231e39c1c6e8d5b3bf754aa +README.zh.md: 1493828388e82c24d5d3a95a8a2e1dc379f152cf diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md index f20648687b..d36e42ccea 100644 --- a/packages/client/ui-sidebar-terminal/README.md +++ b/packages/client/ui-sidebar-terminal/README.md @@ -25,7 +25,7 @@ Choose an installed shell beside a conversation without leaving the Web app. Ren ## Use this package -Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. Choose an installed **Shell** and select **Start terminal**. The last successfully started shell is preselected from localStorage; first use or an unavailable saved choice selects the current default. Use the sidebar's **New tab** control to open more terminals. +Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. Choose an installed **Shell** and select **Start terminal**. The last selected shell is preselected from localStorage; first use or an unavailable saved choice selects the current default. Use the sidebar's **New tab** control to open more terminals. Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached. diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md index c9f3bc8f28..1493828388 100644 --- a/packages/client/ui-sidebar-terminal/README.zh.md +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。选择已安装的 **Shell**,再点击**启动终端**。localStorage 会记住上次成功启动的 shell 并预选;首次使用或已保存选项不可用时,预选当前默认项。通过侧栏的**新标签页**控件打开更多终端。 +展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。选择已安装的 **Shell**,再点击**启动终端**。localStorage 会记住上次选择的 shell 并预选;首次使用或已保存选项不可用时,预选当前默认项。通过侧栏的**新标签页**控件打开更多终端。 双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 已退出的终端计入 Session 数量上限;达到上限时请关闭不用的标签页。 From 3219a2d8e125aa7f8f04071f23f77b8879cc3bf5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 14 Sep 2026 11:10:18 +0800 Subject: [PATCH 31/38] docs(api): refresh terminal dependency graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 3fbca05c5a..2ad16b0afa 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: a6371ef88bc8c49fe04e9ba3e2d3096b57ed2bcc -module-graph.zh.md: 38acfd45af4eed482ccb61ac4657d13beff316a3 +module-graph.md: 64cae46975a6ab586c034bbeee2783b49eec1825 +module-graph.zh.md: af71106dd91a778f12e912593d27907bd1db000b diff --git a/docs/module-graph.md b/docs/module-graph.md index a6371ef88b..64cae46975 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -427,6 +427,7 @@ flowchart TD pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web pkg_api_remotes --> pkg_scope + pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths pkg_client_file_upload --> pkg_scope @@ -1260,7 +1261,6 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | -| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1364,6 +1364,7 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | +| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 38acfd45af..af71106dd9 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -429,6 +429,7 @@ flowchart TD pkg_web_search_perplexity --> pkg_launch_environment pkg_web_search_perplexity --> pkg_web pkg_api_remotes --> pkg_scope + pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths pkg_client_file_upload --> pkg_scope @@ -1262,7 +1263,6 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`api-gateway`](../packages/api/gateway) | `api` | — | -| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1366,6 +1366,7 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | +| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | From 55a17d2e576961997ab8a3e72ef6626f80524108 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 14 Sep 2026 11:46:02 +0800 Subject: [PATCH 32/38] fix(ui): keep markdown table height stable on hover --- .../ui-primitives/src/markdown/MarkdownText.module.css | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index d29c50c99a..7650bb3718 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -198,10 +198,11 @@ scrollbar STYLES (neither hover-conditioned `::-webkit-scrollbar*` rules nor a :hover `scrollbar-color` change reaches the painted bar), so the toggle is `overflow-x` itself — a layout change repaints reliably. The - resting padding matches the themed bar's height, so on an overflowing - table the appearing bar exactly replaces it and nothing below shifts. + resting padding matches the themed bar's height. `scroll` reserves that + height even when the table fits, so removing the padding cannot shift + the content below it. Wheel and trackpad scrolling need the pointer over the table, which is - already the hover that re-enables `auto`. */ + already the hover that re-enables scrolling. */ .tableScroll:global(.md-table-wide) { overflow-x: hidden; padding-bottom: var(--dsh-scrollbar-width, 8px); @@ -209,7 +210,7 @@ .tableScroll:global(.md-table-wide):hover, .tableScroll:global(.md-table-wide):focus-visible { - overflow-x: auto; + overflow-x: scroll; padding-bottom: 0; } From 6d81d0fdbd51841b62b45077d5b554fd1a65bbd2 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 14 Sep 2026 11:47:13 +0800 Subject: [PATCH 33/38] test(web): update table hover scrollbar expectation --- apps/web/tests/markdown-wide-table.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 09f980ab5c..afae437885 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -386,7 +386,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro // Resting hidden overflow keeps the scroll position reachable and intact. expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0) await wide.hover() - await expect.poll(overflowState, { timeout: 5_000 }).toBe('auto 0px') + await expect.poll(overflowState, { timeout: 5_000 }).toBe('scroll 0px') // Pointer leaves: the bar rests hidden again. await page.mouse.move(4, 4) await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px') From 11b211478684e9923cc6eade66fdc82300b0289b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 14 Sep 2026 12:00:29 +0800 Subject: [PATCH 34/38] fix(web): align terminal launcher controls and guide appearance --- .../sidebar-terminal/selection.expected.md | 4 +- .../sidebar-terminal/shell-menu.expected.md | 5 +++ apps/web/tests/sidebar-terminal.e2e.ts | 17 ++++--- .../ui-sidebar-terminal/README.i18n.yaml | 4 +- packages/client/ui-sidebar-terminal/README.md | 4 +- .../client/ui-sidebar-terminal/README.zh.md | 4 +- .../src/client/TerminalBody.module.css | 10 +++-- .../src/client/TerminalBody.tsx | 44 ++++++++++++++----- .../src/client/TerminalIcon.tsx | 20 ++++++++- .../ui-sidebar-terminal/src/client/index.ts | 4 +- .../ui-sidebar-terminal/src/client/locales.ts | 4 +- .../tests/apply.client.spec.ts | 8 +++- .../tests/terminal-body.client.spec.tsx | 12 ++++- 13 files changed, 103 insertions(+), 37 deletions(-) create mode 100644 apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md diff --git a/apps/web/tests/expected/sidebar-terminal/selection.expected.md b/apps/web/tests/expected/sidebar-terminal/selection.expected.md index 5b64f44658..21961c3c4d 100644 --- a/apps/web/tests/expected/sidebar-terminal/selection.expected.md +++ b/apps/web/tests/expected/sidebar-terminal/selection.expected.md @@ -1,5 +1,3 @@ - text: Shell -- combobox "Shell": - - option "bash — /bin/bash" [selected] - - option "sh — /bin/sh" +- button "Shell": bash — /bin/bash - button "Start terminal" diff --git a/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md b/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md new file mode 100644 index 0000000000..50be37e43b --- /dev/null +++ b/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md @@ -0,0 +1,5 @@ +- menu: + - menuitem "bash — /bin/bash": + - text: bash — /bin/bash + - img + - menuitem "sh — /bin/sh" diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index 4a721047e4..ba1ee4ca94 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -161,14 +161,21 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { it('offers installed shells, remembers the choice after reload, and restores without a picker', async () => { onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice')) await page.locator('[data-sidebar-right-expand]').click() - await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() - const selector = page.getByRole('combobox', { name: 'Shell', exact: true }) + const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]') + expect(await entry.innerText()).toBe('New terminal\nRun commands in the Session workspace') + await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' }) + await entry.click() + const selector = page.getByRole('button', { name: 'Shell', exact: true }) await selector.waitFor() - expect(await selector.inputValue()).toBe('/bin/bash') + expect(await selector.innerText()).toContain('bash — /bin/bash') expect(handles).toHaveLength(0) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)), await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode()) - await selector.selectOption('/bin/sh') + await selector.click() + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/shell-menu.expected.md', import.meta.url)), + await page.getByRole('menu').ariaSnapshot(), webSnapshotMode()) + await page.screenshot({ path: `${shots}/shell-menu.png`, fullPage: true }) + await page.getByRole('menuitem', { name: 'sh — /bin/sh', exact: true }).click() expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh') await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true }) await page.getByRole('button', { name: 'Start terminal', exact: true }).click() @@ -184,7 +191,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await page.locator('[data-dockkit-add-tab]').click() await page.locator('[data-sidebar-right-guide-entry="terminal"]').click() await selector.waitFor() - expect(await selector.inputValue()).toBe('/bin/sh') + expect(await selector.innerText()).toContain('sh — /bin/sh') expect(handles).toHaveLength(1) await page.getByRole('button', { name: 'Start terminal', exact: true }).click() await expect.poll(() => handles.length).toBe(2) diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml index 4a97230d15..ad7c48a193 100644 --- a/packages/client/ui-sidebar-terminal/README.i18n.yaml +++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md -README.md: d36e42ccea3a4b419231e39c1c6e8d5b3bf754aa -README.zh.md: 1493828388e82c24d5d3a95a8a2e1dc379f152cf +README.md: dec4c0a88649b74e90afa23ef07d3f11a9dc67cb +README.zh.md: a10cde602ad4edfd13652a93535c4f5d7fd26b52 diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md index d36e42ccea..dec4c0a886 100644 --- a/packages/client/ui-sidebar-terminal/README.md +++ b/packages/client/ui-sidebar-terminal/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Choose an installed shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. +Choose an installed shell from the right sidebar's Start page to run commands in the Session workspace. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration. ## Table of Contents @@ -39,7 +39,7 @@ Displaying a Session after a page reload reopens its retained Host terminals as
Implementation internals — click to expand -This plugin registers the `terminal` type and body/title seats with the right sidebar. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY. +This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide uses a compact dark terminal card, while tab titles retain the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY. A Session header contribution queries Host terminals and opens recovery tabs. Their navigation parameters carry `terminalId` only within the current page; a recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work. diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md index 1493828388..a10cde602a 100644 --- a/packages/client/ui-sidebar-terminal/README.zh.md +++ b/packages/client/ui-sidebar-terminal/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在对话旁选择已安装的 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 +从右侧栏开始页选择已安装的 shell,在会话工作区运行命令。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。 ## 目录 @@ -39,7 +39,7 @@ kind: "package-reference"
实现细节 -插件向右侧栏注册 `terminal` 类型及正文和标题 seat。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。 +插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。 Session header contribution 查询 Host 终端并打开恢复标签页。导航参数中的 `terminalId` 只在当前页面内保留;恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。 diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css index aabba1b3c1..80fa425b11 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css @@ -4,9 +4,11 @@ .screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; background: #17191d; } .screen :global(.xterm) { height: 100%; } .status { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); } -.root button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } -.root button:hover { background: var(--dsw-alias-bg-l2); } +.status button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; } +.status button:hover { background: var(--dsw-alias-bg-l2); } .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); } .launch { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; padding: 12px; } -.shellLabel { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; } -.shellLabel select { min-width: 0; width: 100%; padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); } +.shellField { display: flex; flex: 1; flex-direction: column; gap: 6px; min-width: 0; } +.shellMenu { width: 100%; min-width: 0; } +.shellTrigger { width: 100%; min-width: 0; justify-content: space-between; gap: 8px; } +.shellValue { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx index af404b1e69..d9adf4fae2 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx @@ -1,5 +1,6 @@ /** Sidebar terminal screen and connection recovery. */ -import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react' +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { Button, Menu, IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client' @@ -34,15 +35,7 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable return (
- {state.phase === 'selecting' &&
{ event.preventDefault(); void model.start() }}> - - -
} + {state.phase === 'selecting' && } {(status !== undefined || retry || readOnly) &&
{status} {readOnly && <>{t('readonly')} } @@ -56,6 +49,37 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP ) } +function TerminalLauncher({ state, model, t }: { + state: TerminalViewState + model: TerminalView + t: TerminalBodyProps['t'] +}): ReactNode { + const [open, setOpen] = useState(false) + const selectionId = useId() + const selected = state.shells?.find(shell => shell.path === state.selectedShell) + return
{ event.preventDefault(); void model.start() }}> +
+ {t('shell')} + ({ id: shell.path, label: `${shell.name} — ${shell.path}` })) ?? []} + selectedId={state.selectedShell} + onClose={() => { setOpen(false) }} + onSelect={(path) => { model.selectShell(path); setOpen(false) }} + anchor={} + /> +
+ +
+} + /* oxlint-disable typescript/no-non-null-assertion -- React sets the DOM ref, then these effects initialize and use the emulator. */ function TerminalScreen({ state, model, visible, label }: { state: TerminalViewState diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx index fc9d995c10..df5525a08d 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx @@ -1,9 +1,25 @@ -/** Terminal prompt glyph shared by the guide and tab title. */ +/** Terminal glyphs for the sidebar guide and tab title. */ import type { ReactNode } from 'react' +import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' -/** Render the terminal's decorative prompt icon. @returns a sixteen-pixel glyph. */ +/** + * Render the tab title's terminal prompt in the surrounding text color. + * @returns a decorative sixteen-pixel line glyph. + */ export function TerminalIcon(): ReactNode { return } + +/** + * Render the guide's terminal card within the folder glyph's painted bounds. + * @param props - canvas size and layout class supplied by the guide. + * @returns a dark rounded terminal card with a white prompt. + */ +export function TerminalGuideIcon({ size = 26, className }: IconProps): ReactNode { + return +} diff --git a/packages/client/ui-sidebar-terminal/src/client/index.ts b/packages/client/ui-sidebar-terminal/src/client/index.ts index 0ba160121c..cf71fb2f87 100644 --- a/packages/client/ui-sidebar-terminal/src/client/index.ts +++ b/packages/client/ui-sidebar-terminal/src/client/index.ts @@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import { TerminalIcon } from './TerminalIcon.tsx' +import { TerminalGuideIcon } from './TerminalIcon.tsx' import { TerminalBody } from './TerminalBody.tsx' import { TerminalTitle } from './TerminalTitle.tsx' import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx' @@ -38,7 +38,7 @@ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-terminal.copy') ctx.effect(() => ctx.sidebarRightTabs.register({ id, kind: 'terminal', multiple: true, priority: 'builtin', title: () => t('title'), - guide: [{ order: 20, title: () => t('new'), icon: TerminalIcon }], + guide: [{ order: 20, title: () => t('new'), description: () => t('description'), icon: TerminalGuideIcon }], }), 'ui-sidebar-terminal.type') ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => { ctx.webTerminals.close(sessionId, tab.id, terminalId(sessionId, tab.id)) diff --git a/packages/client/ui-sidebar-terminal/src/client/locales.ts b/packages/client/ui-sidebar-terminal/src/client/locales.ts index 51afac8349..d6ee5a5d17 100644 --- a/packages/client/ui-sidebar-terminal/src/client/locales.ts +++ b/packages/client/ui-sidebar-terminal/src/client/locales.ts @@ -10,7 +10,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Simplified Chinese terminal copy. */ export const zh = { recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端', - shell: 'Shell', start: '启动终端', + shell: 'Shell', start: '启动终端', description: '在会话工作区运行命令', title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…', connecting: '正在连接…', disconnected: '连接已断开。', reconnect: '重新连接', readonly: '此页面当前只读。', control: '接管输入', @@ -27,7 +27,7 @@ export const zh = { /** English terminal copy. */ export const en = { recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery', - shell: 'Shell', start: 'Start terminal', + shell: 'Shell', start: 'Start terminal', description: 'Run commands in the Session workspace', title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…', connecting: 'Connecting…', disconnected: 'Disconnected.', reconnect: 'Reconnect', readonly: 'This view is read-only.', control: 'Take control', diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts index 70d7f1d473..dbbd4fa040 100644 --- a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts @@ -1,5 +1,7 @@ // @vitest-environment jsdom /** Terminal type, copy, seats and explicit cleanup follow the plugin lifetime. */ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' import { Context } from '@deepseek-ai/cordis' import { expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -69,7 +71,11 @@ it('registers terminal views, recovery and cleanup, then releases every contribu try { const definition = h.tabs.get('terminal')! expect(definition.title('sidebar://terminal')).toBe('title') - expect(definition.guide?.map(entry => [entry.order, entry.title()])).toEqual([[20, 'new']]) + expect(definition.guide?.map(entry => [entry.order, entry.title(), entry.description?.()])).toEqual([[20, 'new', 'description']]) + const Icon = definition.guide?.[0]?.icon + if (Icon === undefined) throw new Error('Terminal guide icon was not registered') + expect(renderToStaticMarkup(createElement(Icon, { size: 22 }))).toContain('width="22"') + expect(renderToStaticMarkup(createElement(Icon))).toContain('width="26"') expect(definition.multiple).toBe(true) expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh }) expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([ diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx index d8d80751be..7070b43df9 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx @@ -218,6 +218,7 @@ it.each(titleSurfaces)('shows saved and attached terminal names in the $name', ( title.rerender(chip()) expect(title.container.textContent).toBe('bash') expect(title.container.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true') + expect(title.container.querySelector('svg rect')).toBeNull() }) it.each(titleSurfaces)('renames from the $name pointer target, preserving dragging and isolating editor shortcuts', (surface) => { @@ -314,9 +315,16 @@ it.each([en, zh])('translates known terminal failures while retaining unknown Ho it('lets the user select an installed shell and start it before rendering a screen', () => { const other = { path: '/bin/zsh', name: 'zsh', args: ['-i'] } const h = mount({ phase: 'selecting', writable: false, environment, shells: [info.shell, other], selectedShell: other.path }) - expect(h.view.getByRole('combobox', { name: 'Shell' })).toHaveProperty('value', other.path) + const selector = h.view.getByRole('button', { name: 'Shell' }) + expect(selector.textContent).toContain(other.path) expect(fake.terminals).toHaveLength(0) - fireEvent.change(h.view.getByRole('combobox'), { target: { value: info.shell.path } }) + fireEvent.click(selector) + expect(h.view.getByRole('menu')).toBeTruthy() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(h.view.queryByRole('menu')).toBeNull() + fireEvent.click(selector) + fireEvent.click(h.view.getByRole('menuitem', { name: `bash — ${info.shell.path}` })) + expect(h.view.queryByRole('menu')).toBeNull() expect(h.model.selectShell).toHaveBeenCalledWith(info.shell.path) fireEvent.click(h.view.getByRole('button', { name: 'Start terminal' })) expect(h.model.start).toHaveBeenCalledOnce() From 0a0563cc9b0cb55634b9f85d75343548e562ed3d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 14 Sep 2026 12:13:26 +0800 Subject: [PATCH 35/38] test(web): mock retained terminals in assembled fixtures --- apps/web/tests/assembled-remote.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index fd84fb468c..69739dc7a7 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -2,6 +2,7 @@ * RemoteMock scenario for built-client tests that do not own a Host. * The adjacent JSON is maintained with this module when Remote responses or * the current Session header version change. + * Fixture Sessions have no retained Host terminals to restore. */ import { readFileSync } from 'node:fs' @@ -139,6 +140,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass 'settings/openSettingsDocument': ok({ opened: true }), 'settings/openAgentPresetDirectory': ok({ opened: true }), 'subagents/list': ok({ entries: [], parentAvailable: true }), + 'terminal/list': ok([]), 'skills/list': ok({ skills: [] }), 'session/canOpenWorkspacePath': ok(true), 'session/openWorkspacePath': ok({ opened: true }), From 8ab3579e063ff08d02c3c4e52ad4c4c20495d35c Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 14 Sep 2026 13:34:00 +0800 Subject: [PATCH 36/38] test(web): guard fitting table geometry across interaction --- apps/web/tests/markdown-wide-table.e2e.ts | 46 +++++++++++++++++-- .../src/markdown/MarkdownText.module.css | 6 +-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index afae437885..cf7cdaabb5 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -56,6 +56,7 @@ const TAIL_MARKER = 'MWT_TABLES_DONE' const FILL_MARKER = 'MWT_FILL_C1' const WIDE_MARKER = 'MWT_WIDE_C01' const LONG_CELL_MARKER = 'MWT_LONGCELL_F1' +const SHORT_MARKER = 'MWT_SHORT_C1' const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER] /** Golden-facing names, in {@link MARKERS} order. */ const TABLE_NAMES = ['fill', 'wide', 'long-cell'] @@ -76,13 +77,20 @@ const SENTENCE = 'This cell carries one full sentence so the unwrapped table is const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3) const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。' -/** The assistant markdown: one 3-column fill, one 12-column wide, one long-cell table. */ +/** The assistant markdown includes fitting and overflowing wide tables. */ function tablesMarkdown(): string { const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)] const wideRow = (row: number): string[] => Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`) return [ - 'Three markdown tables exercise the wide-table layout rules.', + 'Markdown tables exercise the wide-table layout rules.', + '', + `| ${SHORT_MARKER} | C2 | C3 | C4 |`, + '| --- | --- | --- | --- |', + '| 1 | 2 | 3 | 4 |', + '| 5 | 6 | 7 | 8 |', + '', + 'The paragraph after the short table stays in place.', '', `| ${FILL_MARKER} | Current approach | Proposed approach |`, '| --- | --- | --- |', @@ -103,7 +111,7 @@ function tablesMarkdown(): string { ].join('\n') } -/** Build one closed, invariant-checked session fixture carrying the three tables. */ +/** Build one closed, invariant-checked session fixture carrying the tables. */ function wideTableFixture(): string { const session = Session.create(SessionId('markdown-wide-table-source')) const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) @@ -257,7 +265,8 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro beforeAll(async () => { scaffold = await launchWebScaffold({}) await seedSession(scaffold, wideTableFixture(), SEED_ID) - browser = await chromium.launch() + // The geometry assertions include the space occupied by native scrollbars. + browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] }) page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) @@ -393,6 +402,35 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro expect(tripwire.pageErrors).toEqual([]) }, 120_000) + it('keeps a fitting wide table and its following paragraph stationary during interaction', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-short-table-height')) + await settleAt(1680) + const short = page.locator('[class*="tableScroll"]', { hasText: SHORT_MARKER }) + await short.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' })) + await page.mouse.move(4, 4) + await short.evaluate(element => element.blur()) + expect(await short.evaluate(element => element.classList.contains('md-table-wide'))).toBe(true) + expect(await short.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1) + const position = () => short.evaluate((element) => { + const following = element.nextElementSibling + if (following === null) throw new Error('short table has no following paragraph') + return { + height: element.getBoundingClientRect().height, + followingTop: following.getBoundingClientRect().top, + } + }) + const resting = await position() + await short.hover() + await expect.poll(position).toEqual(resting) + await page.mouse.move(4, 4) + await short.focus() + expect(await short.evaluate(element => document.activeElement === element)).toBe(true) + await expect.poll(position).toEqual(resting) + await short.evaluate(element => element.blur()) + await expect.poll(position).toEqual(resting) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + it('keeps the fill/scroll relations under page zoom', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom')) await sweep() diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 7650bb3718..652affcf85 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -198,9 +198,9 @@ scrollbar STYLES (neither hover-conditioned `::-webkit-scrollbar*` rules nor a :hover `scrollbar-color` change reaches the painted bar), so the toggle is `overflow-x` itself — a layout change repaints reliably. The - resting padding matches the themed bar's height. `scroll` reserves that - height even when the table fits, so removing the padding cannot shift - the content below it. + resting padding matches the themed bar's height on the WebKit scrollbar + path. There, `scroll` reserves that height even when the table fits. + Other engines and overlay scrollbars may reserve a different height. Wheel and trackpad scrolling need the pointer over the table, which is already the hover that re-enables scrolling. */ .tableScroll:global(.md-table-wide) { From 1119fa254466ea3b5f490e48fc8b2d1f840c5164 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 14 Sep 2026 13:50:23 +0800 Subject: [PATCH 37/38] test(web): refresh the Auto-review trajectory golden for PTC summaries The trajectory ledger now summarizes a recorded run_code call with the program description instead of its raw JSON arguments, and marks it with the code-inspection icon. Refresh the Auto-review denial golden so the replayed ARIA expectation matches the shipped rendering. --- snapshots/web/auto-review-denial/ui.expected.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/snapshots/web/auto-review-denial/ui.expected.md b/snapshots/web/auto-review-denial/ui.expected.md index c430cf3f33..b849cc3a7b 100644 --- a/snapshots/web/auto-review-denial/ui.expected.md +++ b/snapshots/web/auto-review-denial/ui.expected.md @@ -121,9 +121,11 @@ - 'button "Request #2"' - text: ASSISTANT - cell "(tool call only)" - - 'row "TOOL, run_code {\"code\":\"try { await tools.bash({command: \"echo hidden-input-ptc\"}) } catch (error) { return \"denial caught\" }\",\"description\":\"Catch an Auto-denied inner call\"}"': + - row "TOOL, run_code Catch an Auto-denied inner call": - cell "TOOL" - - 'cell "run_code{\"code\":\"try { await tools.bash({command: \"echo hidden-input-ptc\"}) } catch (error) { return \"denial caught\" }\",\"description\":\"Catch an Auto-denied inner call\"} → denial caught"' + - cell "run_codeCatch an Auto-denied inner call → denial caught": + - img + - text: run_codeCatch an Auto-denied inner call → denial caught - 'row "SUBTOOL, bash {\"command\":\"echo hidden-input-ptc\"}" [selected]': - cell "SUBTOOL" - 'cell "bash{\"command\":\"echo hidden-input-ptc\"} → AUTO_REVIEW_DENIED"' From f0463cf80ddc3c60594f6c7a7c119d17605c4d5e Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 14 Sep 2026 13:53:09 +0800 Subject: [PATCH 38/38] fix(test): use block bodies for void browser callbacks --- apps/web/tests/markdown-wide-table.e2e.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index cf7cdaabb5..45d889c6d4 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -406,9 +406,9 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-short-table-height')) await settleAt(1680) const short = page.locator('[class*="tableScroll"]', { hasText: SHORT_MARKER }) - await short.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' })) + await short.evaluate((element) => { element.scrollIntoView({ block: 'center', behavior: 'instant' }) }) await page.mouse.move(4, 4) - await short.evaluate(element => element.blur()) + await short.evaluate((element) => { element.blur() }) expect(await short.evaluate(element => element.classList.contains('md-table-wide'))).toBe(true) expect(await short.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1) const position = () => short.evaluate((element) => { @@ -426,7 +426,7 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro await short.focus() expect(await short.evaluate(element => document.activeElement === element)).toBe(true) await expect.poll(position).toEqual(resting) - await short.evaluate(element => element.blur()) + await short.evaluate((element) => { element.blur() }) await expect.poll(position).toEqual(resting) expect(tripwire.pageErrors).toEqual([]) }, 120_000)