Compare commits

..
4 Commits
Author SHA1 Message Date
Menghuan1918 069c754877 feat: Git 面板真正的 diff 显示 + VSCode 式历史与右键高级操作,v0.3.0
- 真正的统一 diff 显示:hunk 头 + 双行号 + 红绿着色,长行换行无横向滚动,untracked 全新增视图
- 点击变更/历史自动打开独立 diff tab(首次在下半栏新增分栏,同变更去重聚焦,临时页签刷新不恢复)
- 历史行:分支徽章 + 作者/相对时间,懒加载分页(20 条/页 + 加载更多)
- 右键菜单:文件行(打开/暂存/放弃更改/复制路径)、历史行(复制哈希/信息/还原/捡取),破坏性操作确认弹窗
- 修复暂存/未暂存分类错误(' M' 被误标为已暂存导致 diff 恒空)
- 修复 git 相对路径解析(会话在仓库子目录时 diff/discard/show/read 可用)
- host 新增 git.commit-diff / git.discard / git.revert / git.cherry-pick 路由与 log 分页
2026-08-10 22:29:24 +08:00
Atlas Zeng feea87557c Merge pull request #2 from dsh-external/feat/agent-terminal-tools
feat: 为 agent 暴露 8 个终端工具,侧边栏自动同步 agent 终端 tab,v0.2.0
2026-08-10 21:49:58 +08:00
Menghuan1918 8edfbfde44 fix: 修复 agent 终端工具审查问题(terminal_list 输出契约、跨会话归属、UTF-8 截断边界等),工具注入改为设置面板开关(默认关闭) 2026-08-10 21:43:58 +08:00
HuanLinOTO 6f62d19905 feat: 为 agent 暴露 8 个终端工具(terminal_create/list/send/read/wait_for/resize/signal/close),侧边栏自动同步 agent 终端 tab,v0.2.0 2026-08-10 19:08:58 +08:00
29 changed files with 3784 additions and 145 deletions
+10 -2
View File
@@ -104,13 +104,20 @@ dsh registry enable dsh-external/dsh-better-sidebar
### 💻 终端
- xterm.js + node-pty **真实 shell**,每会话最多 3 个
- xterm.js + node-pty **真实 shell**,每会话最多 3 个(仅 UI 手动新建的 tab 计入上限)
- **Tab 打开期间一直保活**:切换 Tab/分栏、切换会话(30 秒内返回)、刷新页面都重连到同一个 shell 进程并回放转录
- 真正关闭 Tab 立即释放配额;连续失败停止重连并显示原因与重试按钮
- **Agent 终端(默认关闭)**:设置页开启「为模型注入终端工具」后,模型可通过 `terminal_create` / `list` / `send` / `read` / `wait_for` / `resize` / `signal` / `close` 8 个工具创建持久终端,自动同步为侧边栏 tab(id `agent:<uuid>`,不占用 3 个 UI 上限)。终端由 agent 拥有——`terminal_close` 或用户关闭 tab 才销毁,WS 断开(刷新/切会话)不杀进程;`terminal_signal(SIGINT)` 通过写入 `\x03` 控制字符实现,Windows ConPTY 与 POSIX 通用
### 🌿 Git 面板
- status 暂存/未暂存分组、按文件 diff(HEAD vs 工作区/暂存区)、stage/unstage(单个与全部)、commit、分支切换、提交历史
- **真正的 diff 显示**:解析 `git diff` 统一格式——`@@` hunk 头、旧/新双行号对齐、上下文/删除/新增行红绿着色(DSH 语义色 token),长行自动换行、无横向滚动;二进制文件显示提示、超长 diff 折叠展开、untracked 文件渲染为全新增视图
- **点击即开 diff tab(VSCode 式)**:点击变更文件行或历史提交行,自动打开独立 diff tab——首次打开时当前栏纵向一分为二、diff 默认落在**下半栏**,之后新 diff 堆叠进同一栏;同一变更(路径+暂存侧 / 提交)只存在一个实例,重复点击聚焦已有 tab;diff tab 为临时页签(刷新后不恢复,同 VSCode diff 编辑器),带手动刷新按钮
- **VSCode 式历史**:提交行 = 短哈希 + 主题,第二行 = 分支/标签徽章 + 作者 · 相对时间;**懒加载分页**(每页 20 条,底部「加载更多」,避免长历史挤爆面板)
- **右键高级操作**:
- 文件行:在编辑器中打开 / 暂存 / 取消暂存 / 放弃更改(确认弹窗)/ 复制相对·绝对路径
- 历史行:查看提交差异 / 复制短哈希 / 复制完整哈希 / 复制提交信息 / 还原此提交(revert)/ 捡取此提交(cherry-pick),破坏性操作均有确认弹窗
- 暂存/未暂存按 git XY 状态精确分组(`MM` 双区显示)、stage/unstage(单个与全部)、commit(Ctrl+Enter)、分支切换
### 🧩 Subagent 页面
@@ -148,6 +155,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
- **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
- **为模型注入终端工具**(默认关闭):开启后模型可通过 8 个 `terminal_*` 工具创建并操作侧边栏终端;关闭会注销工具并释放已创建的 agent 终端
## ⌨️ 快捷键速查
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.1.9",
"version": "0.3.0",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离",
"engines": {
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.1.9",
"version": "0.3.0",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.",
"private": true,
"type": "module",
@@ -50,6 +50,7 @@
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
@@ -59,8 +60,10 @@
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -101,6 +104,7 @@
"xterm": "^5.3.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "link:/Users/menghuan/.dsh/source/current/packages/core/agent",
"@deepseek-ai/dsh-client-runtime": "link:/Users/menghuan/.dsh/source/current/packages/client/runtime",
"@deepseek-ai/dsh-client-schema-form": "link:/Users/menghuan/.dsh/source/current/packages/client/schema-form",
"@deepseek-ai/dsh-client-ui-conversation": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation",
@@ -110,8 +114,10 @@
"@deepseek-ai/dsh-client-web-react": "link:/Users/menghuan/.dsh/source/current/packages/client/web-react",
"@deepseek-ai/dsh-host-webserver": "link:/Users/menghuan/.dsh/source/current/packages/host/webserver",
"@deepseek-ai/dsh-invariants": "link:/Users/menghuan/.dsh/source/current/packages/support/invariants",
"@deepseek-ai/dsh-llm": "link:/Users/menghuan/.dsh/source/current/packages/llm/llm",
"@deepseek-ai/dsh-session": "link:/Users/menghuan/.dsh/source/current/packages/core/session",
"@deepseek-ai/dsh-settings": "link:/Users/menghuan/.dsh/source/current/packages/settings/settings",
"@deepseek-ai/dsh-tools": "link:/Users/menghuan/.dsh/source/current/packages/core/tools",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
+20 -11
View File
@@ -111,39 +111,48 @@ importers:
'@cordisjs/plugin-loader':
specifier: ^1.0.0-rc.5
version: 1.0.0-rc.5(cordis@4.0.0-rc.7)
'@deepseek-ai/dsh-agent':
specifier: link:/Users/menghuan/.dsh/source/current/packages/core/agent
version: link:/Users/menghuan/.dsh/source/current/packages/core/agent
'@deepseek-ai/dsh-client-runtime':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/runtime
version: link:../../.dsh/source/current/packages/client/runtime
version: link:/Users/menghuan/.dsh/source/current/packages/client/runtime
'@deepseek-ai/dsh-client-schema-form':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/schema-form
version: link:../../.dsh/source/current/packages/client/schema-form
version: link:/Users/menghuan/.dsh/source/current/packages/client/schema-form
'@deepseek-ai/dsh-client-ui-conversation':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation
version: link:../../.dsh/source/current/packages/client/ui-conversation
version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation
'@deepseek-ai/dsh-client-ui-primitives':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives
version: link:../../.dsh/source/current/packages/client/ui-primitives
version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives
'@deepseek-ai/dsh-client-ui-settings':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings
version: link:../../.dsh/source/current/packages/client/ui-settings
version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings
'@deepseek-ai/dsh-client-ui-slots':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots
version: link:../../.dsh/source/current/packages/client/ui-slots
version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots
'@deepseek-ai/dsh-client-web-react':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/web-react
version: link:../../.dsh/source/current/packages/client/web-react
version: link:/Users/menghuan/.dsh/source/current/packages/client/web-react
'@deepseek-ai/dsh-host-webserver':
specifier: link:/Users/menghuan/.dsh/source/current/packages/host/webserver
version: link:../../.dsh/source/current/packages/host/webserver
version: link:/Users/menghuan/.dsh/source/current/packages/host/webserver
'@deepseek-ai/dsh-invariants':
specifier: link:/Users/menghuan/.dsh/source/current/packages/support/invariants
version: link:../../.dsh/source/current/packages/support/invariants
version: link:/Users/menghuan/.dsh/source/current/packages/support/invariants
'@deepseek-ai/dsh-llm':
specifier: link:/Users/menghuan/.dsh/source/current/packages/llm/llm
version: link:/Users/menghuan/.dsh/source/current/packages/llm/llm
'@deepseek-ai/dsh-session':
specifier: link:/Users/menghuan/.dsh/source/current/packages/core/session
version: link:../../.dsh/source/current/packages/core/session
version: link:/Users/menghuan/.dsh/source/current/packages/core/session
'@deepseek-ai/dsh-settings':
specifier: link:/Users/menghuan/.dsh/source/current/packages/settings/settings
version: link:../../.dsh/source/current/packages/settings/settings
version: link:/Users/menghuan/.dsh/source/current/packages/settings/settings
'@deepseek-ai/dsh-tools':
specifier: link:/Users/menghuan/.dsh/source/current/packages/core/tools
version: link:/Users/menghuan/.dsh/source/current/packages/core/tools
'@types/node':
specifier: ^24.0.0
version: 24.13.3
+519
View File
@@ -0,0 +1,519 @@
/**
* Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
* sessions created by the model through the `terminal_create` tool. Each
* handle survives across tool calls (and across WebSocket disconnects from
* the sidebar view) until the model calls `terminal_close` or the user
* closes the corresponding sidebar tab — tmux semantics, scoped per agent
* session.
*
* This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
* `${sessionId}:${tabId}` and capped per session, while agent terminals are
* keyed by uuid and uncapped (the model is trusted to close unused ones).
* Both registries share the same shell resolver and spawn-helper fix.
*/
import { randomUUID } from 'node:crypto'
import * as nodePty from 'node-pty'
import { ensureSpawnHelper } from './pty-manager.ts'
import { SidebarError } from './wire.ts'
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
const TRANSCRIPT_LIMIT = 1 << 20
/** POSIX signals the registry forwards to a live pty. */
export const ALLOWED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGTSTP'] as const
/** Signal name accepted by `signal()`. */
export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number]
/** Default read page size (lines) when the caller omits `count`. */
export const DEFAULT_READ_COUNT = 500
/** Smallest pty dimension the registry accepts (mirrors the tool contract). */
export const TERMINAL_DIM_MIN = 2
/** Largest pty dimension the registry accepts (mirrors the tool contract). */
export const TERMINAL_DIM_MAX = 1024
/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
export function clampDims(cols: number, rows: number): { cols: number; rows: number } {
const clamp = (value: number): number =>
Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value)))
return { cols: clamp(cols), rows: clamp(rows) }
}
/**
* Serializable snapshot of one agent terminal — the shape the model sees
* through `terminal_list` and the sidebar sees through the push endpoint.
* Carries no pty reference and no transcript (those are reached through
* dedicated read/attach paths), and no sessionId: ownership is registry
* internals, scoped by the caller (list filters by session, the push
* endpoint scopes by its query param), never part of the serialized view.
*/
export interface AgentTerminalSnapshot {
/** Stable opaque handle the model passes back to other terminal_* tools. */
uuid: string
/** Display title the model chose at create time. */
title: string
/** The command the model asked to run at create time (verbatim). */
command: string
/** Whether the top-level process has exited. */
exited: boolean
/** Exit code if exited normally; absent until the process exits. */
exitCode?: number | null
/** Exit signal name if the process was killed by a signal; null otherwise. */
exitSignal?: string | null
}
/** Map a POSIX signal number to its conventional name (best-effort). */
const SIGNAL_NAMES: Record<number, string> = {
1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 6: 'SIGABRT',
9: 'SIGKILL', 11: 'SIGSEGV', 13: 'SIGPIPE', 14: 'SIGALRM', 15: 'SIGTERM',
17: 'SIGCHLD', 18: 'SIGCONT', 19: 'SIGSTOP', 20: 'SIGTSTP',
}
/** Convert a raw signal number to a name (or null when absent/unknown). */
function signalNameOf(signal: number | null | undefined): string | null {
if (signal === null || signal === undefined) return null
return SIGNAL_NAMES[signal] ?? `signal ${signal}`
}
/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
if (needle === '') return undefined
const idx = transcript.indexOf(needle)
if (idx === -1) return undefined
// Walk the transcript up to the match index, counting newlines to derive
// the 0-based line; the column is the offset within that line.
let line = 0
let lineStart = 0
for (let i = 0; i < idx; i += 1) {
if (transcript.charCodeAt(i) === 0x0a /* \n */) {
line += 1
lineStart = i + 1
}
}
return { line, column: idx - lineStart }
}
/** One live agent terminal. */
export interface AgentTerminalHandle {
/** Stable opaque handle. */
uuid: string
/** Owning conversation id. */
sessionId: string
/** Display title. */
title: string
/** The command written to stdin right after spawn. */
command: string
/** The working directory the process was spawned with. */
cwd: string
/** The live pty process. */
pty: nodePty.IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string
/** Whether the top-level process exited (transcript stays replayable). */
exited: boolean
/** Exit code once known. */
exitCode?: number | null
/** Exit signal number once known (POSIX only; undefined on Windows). */
exitSignal?: number | null
}
/** Read result shape (mirrors the official tool-pty terminal_read contract). */
export interface AgentTerminalReadResult {
/** The slice of transcript text for the requested page. */
text: string
/** Total lines in the retained transcript (the page may be a subset). */
totalLines: number
/** 0-based index of the first line in `text` (inclusive). */
lineBegin: number
/** 0-based index of the last line in `text` (exclusive). */
lineEnd: number
}
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
export type AgentTerminalWaitResult =
| {
/** The needle was found in the transcript. */
kind: 'found'
/** The matched substring. */
needle: string
/** 0-based line index (in the retained transcript) where the needle first appeared. */
line: number
/** 0-based column index within that line where the match starts. */
column: number
/** Elapsed wall-clock milliseconds from the wait start to the match. */
elapsedMs: number
}
| {
/** The needle did not appear before the timeout. */
kind: 'timeout'
/** The needle that was awaited. */
needle: string
/** The configured timeout in milliseconds. */
timeoutMs: number
/** Total lines retained when the timeout fired (call terminal_read to inspect). */
totalLines: number
}
| {
/** The terminal exited before the needle appeared. */
kind: 'exited'
/** The needle that was awaited. */
needle: string
/** The exit code, if known. */
exitCode?: number | null
/** The exit signal name, if the process was killed by a signal. */
exitSignal?: string | null
}
/** Snapshot projection of a handle (drops the pty reference and transcript). */
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
const out: AgentTerminalSnapshot = {
uuid: handle.uuid,
title: handle.title,
command: handle.command,
exited: handle.exited,
}
if (handle.exited) {
out.exitCode = handle.exitCode ?? null
out.exitSignal = signalNameOf(handle.exitSignal)
}
return out
}
/**
* The agent terminal registry. The constructor takes the resolved shell
* binary (the same `defaultShell()` the UI-tab registry uses) and runs the
* spawn-helper chmod fix once at construction so the first agent terminal
* does not race a lazy fixer.
*/
export class AgentPtyRegistry {
private readonly sessions = new Map<string, AgentTerminalHandle>()
private readonly changeListeners = new Set<() => void>()
constructor(private readonly shell: string) {
ensureSpawnHelper()
}
/**
* Spawn one agent terminal: start the shell in `cwd`, then write
* `command + '\n'` to stdin so the command runs in the fresh shell. The
* terminal stays alive after the command exits — the model can send more
* input through `terminal_send` until it calls `terminal_close` or the
* user closes the sidebar tab. An empty `command` spawns a bare shell.
* @returns the new handle's uuid (the model-facing opaque id).
*/
create(
sessionId: string,
title: string,
command: string,
cwd: string,
cols = 80,
rows = 24,
): string {
const uuid = randomUUID()
const dims = clampDims(cols, rows)
const pty = nodePty.spawn(this.shell, [], {
name: 'xterm-256color',
cols: dims.cols,
rows: dims.rows,
cwd,
env: { ...process.env },
})
const handle: AgentTerminalHandle = {
uuid,
sessionId,
title,
command,
cwd,
pty,
transcript: '',
exited: false,
}
pty.onData((data) => {
handle.transcript += data
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
}
})
pty.onExit(({ exitCode, signal }) => {
handle.exited = true
handle.exitCode = exitCode
handle.exitSignal = signal
this.notify()
})
if (command !== '') {
// Write the command + Enter so it runs in the freshly spawned shell.
// Use \r (carriage return) — the actual character a terminal sends for
// the Enter key — not \n (line feed). PowerShell treats a bare \n as a
// soft line break (continuation prompt ">>") rather than a command
// submit; \r is the cross-shell Enter semantics on both POSIX and Windows.
try {
pty.write(`${command}\r`)
} catch {
// A spawn that failed between onData and onExit surfaces its own
// exit; the create call still returns the uuid so the model can
// read the transcript and see the failure.
}
}
this.sessions.set(uuid, handle)
this.notify()
return uuid
}
/** All live agent terminals belonging to one conversation. */
list(sessionId: string): AgentTerminalSnapshot[] {
const out: AgentTerminalSnapshot[] = []
for (const handle of this.sessions.values()) {
if (handle.sessionId === sessionId) out.push(snapshotOf(handle))
}
return out
}
/** Resolve a live handle by uuid, or throw `not-found`. */
private expect(uuid: string): AgentTerminalHandle {
const handle = this.sessions.get(uuid)
if (handle === undefined) {
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
}
return handle
}
/**
* Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
* The model-facing tools call this before every uuid-keyed operation: a
* uuid from another session is indistinguishable from an unknown one, so a
* model can never reach (or probe) a terminal it does not own.
*/
assertOwned(uuid: string, sessionId: string): AgentTerminalHandle {
const handle = this.expect(uuid)
if (handle.sessionId !== sessionId) {
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
}
return handle
}
/** Resolve a handle's snapshot, or undefined if it does not exist. */
snapshot(uuid: string): AgentTerminalSnapshot | undefined {
const handle = this.sessions.get(uuid)
return handle === undefined ? undefined : snapshotOf(handle)
}
/** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
send(uuid: string, text: string): void {
const handle = this.expect(uuid)
if (handle.exited) {
throw new SidebarError('bad-request', `agent terminal "${uuid}" has exited`, 400)
}
handle.pty.write(text)
}
/**
* Read one bounded page of the retained transcript. `offset` is a 0-based
* line index from the start of the retained transcript (default 0);
* `count` caps the page size (default 500). A negative `offset` reads
* from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
* so the model can paginate.
*/
read(uuid: string, offset?: number, count?: number): AgentTerminalReadResult {
const handle = this.expect(uuid)
const lines = handle.transcript.split('\n')
const totalLines = lines.length
const pageSize = Math.max(1, Math.min(count ?? DEFAULT_READ_COUNT, DEFAULT_READ_COUNT))
let start: number
if (offset === undefined || offset === 0) {
start = 0
} else if (offset < 0) {
// Negative offset: read from the end (e.g. -50 → last 50 lines).
start = Math.max(0, totalLines + offset)
} else {
start = Math.min(offset, totalLines)
}
const end = Math.min(start + pageSize, totalLines)
const slice = lines.slice(start, end).join('\n')
return {
text: slice,
totalLines,
lineBegin: start,
lineEnd: end,
}
}
/**
* Resize a terminal's pty, clamped to the 2..1024 sane range.
* @returns the dimensions actually applied (the caller echoes these, so the
* reported value always matches the pty).
*/
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
const handle = this.expect(uuid)
const dims = clampDims(cols, rows)
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
return dims
}
/**
* Wait for `needle` to appear in a terminal's transcript, or for the
* terminal to exit, or for the timeout to elapse — whichever happens
* first. The wait polls the live transcript every ~50ms and short-circuits
* on `signal` abort (re-thrown as the abort reason so the tool layer
* surfaces cancellation).
*
* The match scans the FULL retained transcript on each poll, not just the
* delta since the last poll — a needle that scrolled past the most recent
* chunk but is still within the ~1 MiB bound is still a match. The
* returned line/column locate the FIRST occurrence (oldest), which is what
* a user watching the terminal would have seen first.
*
* The implementation uses polling (not pty onData subscription) because
* node-pty's onData fires before the registry's own onData listener
* updates the transcript (listener order is not guaranteed), and on
* Windows ConPTY output can arrive in bursts with batching delays that
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
* interactive use and simple enough to be obviously correct.
* @param uuid - terminal to watch.
* @param needle - substring to search for (case-sensitive, verbatim).
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
* @returns one of `found` / `timeout` / `exited`.
*/
async waitFor(
uuid: string,
needle: string,
timeoutMs = 10_000,
signal?: AbortSignal,
): Promise<AgentTerminalWaitResult> {
if (needle === '') {
throw new SidebarError('bad-request', 'needle must be a non-empty string', 400)
}
const handle = this.expect(uuid)
const timeout = Math.max(100, Math.floor(timeoutMs))
const start = Date.now()
const deadline = start + timeout
// Fast path: already exited, or the needle is already in the transcript
// (a `terminal_send` may have produced the expected output before this
// call even started).
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
const firstHit = locateNeedle(handle.transcript, needle)
if (firstHit !== undefined) {
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
}
// Poll loop: check the transcript every 50ms, exit on match / exit /
// abort / timeout. The handle is read live each iteration (its transcript
// and exited fields mutate as the pty produces output).
while (true) {
if (signal?.aborted) signal.throwIfAborted()
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
const hit = locateNeedle(handle.transcript, needle)
if (hit !== undefined) {
return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
}
if (Date.now() >= deadline) {
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
}
await new Promise(resolve => {
const t = setTimeout(resolve, 50)
// Allow the Node process to exit even if the timer is pending.
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
})
}
}
/**
* Send a POSIX signal to a terminal's foreground process.
*
* Two delivery paths, by signal kind:
* - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
* writing the corresponding control character to the pty stdin. This is
* how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
* line discipline (POSIX ISIG mode) or the ConPTY input pipeline
* (Windows), which translates it into a SIGINT/SIGTSTP for the
* foreground process group. This works on every platform — calling
* `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
* but writing `\x03` is universally correct.
* - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
* which maps to the platform's process-termination path (POSIX
* `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
* control characters.
*/
signal(uuid: string, signal: AgentTerminalSignal): void {
const handle = this.expect(uuid)
if (handle.exited) return
// Ctrl+C → 0x03 (ETX), Ctrl+Z → 0x1A (SUB). Writing these to the pty
// master is the cross-platform way to deliver the signal to the
// foreground process group through the terminal's input pipeline.
if (signal === 'SIGINT' || signal === 'SIGTSTP') {
const ctrlByte = signal === 'SIGINT' ? '\x03' : '\x1a'
try {
handle.pty.write(ctrlByte)
} catch {
// A pty that rejects writes is already tearing down; the next
// onExit will mark it exited. Not an error from the caller's view.
}
return
}
// SIGKILL / SIGTERM / SIGHUP: use the process-termination path.
try {
handle.pty.kill(signal)
} catch {
// node-pty on Windows rejects named signals other than the default;
// fall back to the default kill (TerminateProcess on Windows,
// SIGKILL-equivalent on POSIX) so the signal still takes effect.
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
}
}
/**
* Close a terminal and drop its state. Idempotent: a second close of the
* same uuid is a no-op. Returns true iff a live handle was actually
* dropped.
*/
close(uuid: string): boolean {
const handle = this.sessions.get(uuid)
if (handle === undefined) return false
this.sessions.delete(uuid)
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
this.notify()
return true
}
/** Resolve a live handle by uuid (for the WS attach path). */
get(uuid: string): AgentTerminalHandle | undefined {
return this.sessions.get(uuid)
}
/**
* Subscribe to registry changes (create / close / exit). The sidebar push
* endpoint uses this to forward snapshots to the connected view. Returns
* the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.changeListeners.add(listener)
return () => { this.changeListeners.delete(listener) }
}
/** Close every agent terminal (plugin teardown). */
disposeAll(): void {
for (const uuid of [...this.sessions.keys()]) this.close(uuid)
}
/** Fire every change listener (callers wrap in try/catch if needed). */
private notify(): void {
for (const listener of [...this.changeListeners]) {
try {
listener()
} catch {
// A listener throwing must not break the others or the registry.
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
/**
* The diff tab: one change opened from the git panel, like VSCode's diff
* editor. A worktree ref loads the file's unified diff (`git diff`, staged or
* not; untracked files — which git diff never covers — render as a full-file
* addition from their content), a commit ref loads the commit's full patch
* (`git.show`-style). The header carries a refresh button because the tab
* stays mounted while the git panel's staging/discard operations change the
* very content it shows.
*/
import { useCallback, useEffect, useState } from 'react'
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from './api.ts'
import { api } from './api.ts'
import type { SidebarDiffRef } from './state.ts'
import { DiffView } from './DiffView.tsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** The loaded diff surface (untracked content rendered as a full addition). */
interface DiffData {
diff: string
untracked?: string
}
export function DiffTab(props: { sessionId: string; cwd: string | undefined; diff: SidebarDiffRef }) {
const { sessionId, cwd, diff } = props
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<DiffData | null>(null)
const [tick, setTick] = useState(0)
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
useEffect(() => {
let cancelled = false
const scope: SessionScope = { sessionId, cwd }
setLoading(true)
setError(null)
setData(null)
const load = async (): Promise<void> => {
try {
if (diff.kind === 'commit') {
const result = await api.gitCommitDiff(scope, diff.hashFull)
if (!cancelled) setData({ diff: result.diff })
return
}
let result = await api.gitDiff(scope, diff.path, diff.staged)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once: the ref
// may predate the staged-flag fix, or the change moved sides (a
// file staged after its tab opened). Both sides empty means the
// file genuinely has no text changes.
const other = await api.gitDiff(scope, diff.path, !diff.staged)
if (other.diff !== '') result = other
}
if (result.diff !== '') {
if (!cancelled) setData({ diff: result.diff })
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition; anything else is a genuine no-text-change.
if (diff.untracked === true && !diff.staged) {
const text = await api.fsRead(scope, diff.path)
if (!cancelled) {
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
}
return
}
if (!cancelled) setData({ diff: '' })
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [sessionId, cwd, diff, tick])
return (
<div className={css.gitDiffTab}>
<div className={css.gitDiffTabHeader}>
<span className={css.gitDiffTabTitle} title={diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}>
{diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}
</span>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={refresh}
>
<IconRefreshOutline16 />
</button>
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
{!loading && error !== null && <div className={css.gitError}>{t('diffLoadError')}: {error}</div>}
{!loading && error === null && data !== null && (
<>
{data.untracked !== undefined
? <DiffView diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffView diff={data.diff} />}
{data.diff === '' && data.untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
</>
)}
</div>
)
}
+266
View File
@@ -0,0 +1,266 @@
/**
* The real diff surface for the git panel: parses the host's unified diff
* text (`git diff` / `git show`) and renders it VSCode-style — per-file
* sections with hunks (`@@ -a,b +c,d @@` headers), old/new line-number
* gutters, and aligned context / deleted / added rows colored through the
* DSH tokens. Untracked files produce no `git diff` output, so the caller
* can pass the file content to render as a full-file addition instead.
*
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
* cases are unit-tested without a DOM.
*/
import { useMemo, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** One rendered diff line. */
export interface DiffLine {
kind: 'ctx' | 'del' | 'add' | 'meta'
/** The line content without its diff marker ('' for the no-newline marker). */
text: string
/** Old-side line number (null for pure additions / metadata). */
oldNum: number | null
/** New-side line number (null for pure deletions / metadata). */
newNum: number | null
}
/** One parsed hunk. */
export interface DiffHunk {
/** The old-side start line (`-a[,b]`). */
oldStart: number
/** The new-side start line (`+c[,d]`). */
newStart: number
/** The section text after the trailing `@@` (may be empty). */
header: string
lines: DiffLine[]
}
/** One parsed file section of a unified diff. */
export interface DiffFile {
/** The `---` path verbatim ('/dev/null' for a new file). */
oldPath: string
/** The `+++` path verbatim ('/dev/null' for a deleted file). */
newPath: string
/** The file changed with binary content: no hunks to draw. */
binary: boolean
hunks: DiffHunk[]
}
/** The parsed unified diff. */
export interface ParsedDiff {
files: DiffFile[]
}
/** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */
function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line)
if (match === null) return null
return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' }
}
/**
* Parse `git diff --no-color` output into file sections and hunks. Rows
* outside a file section (leading noise) and metadata rows between the
* `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode
* changes, rename/similarity lines) are skipped; a section that never
* reaches a hunk (a mode/rename-only change) stays hunkless so the caller
* can still draw its path.
*/
export function parseUnifiedDiff(text: string): ParsedDiff {
const files: DiffFile[] = []
let current: DiffFile | null = null
let inHunk = false
let hunk: DiffHunk | null = null
let oldNum = 0
let newNum = 0
const flushHunk = (): void => {
if (current !== null && hunk !== null) current.hunks.push(hunk)
hunk = null
inHunk = false
}
for (const raw of text.split('\n')) {
if (raw.startsWith('diff --git ')) {
flushHunk()
current = { oldPath: '', newPath: '', binary: false, hunks: [] }
files.push(current)
continue
}
if (current === null) continue
if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
flushHunk()
current.binary = true
continue
}
if (raw.startsWith('--- ')) {
flushHunk()
current.oldPath = raw.slice(4)
continue
}
if (raw.startsWith('+++ ')) {
current.newPath = raw.slice(4)
continue
}
const header = parseHunkHeader(raw)
if (header !== null) {
flushHunk()
hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] }
oldNum = header.oldStart
newNum = header.newStart
inHunk = true
continue
}
if (!inHunk || hunk === null) continue
const marker = raw[0]
if (marker === '\\') {
// `\ No newline at end of file`: metadata attached to the previous row.
hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null })
continue
}
if (marker === ' ') {
hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum })
oldNum += 1
newNum += 1
} else if (marker === '-') {
hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null })
oldNum += 1
} else if (marker === '+') {
hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum })
newNum += 1
} else {
// Not a diff line (a hunk can never contain one): stop the hunk.
flushHunk()
}
}
flushHunk()
return { files }
}
/** Build the untracked-file shape: one file, one hunk of pure additions. */
function untrackedFile(path: string, content: string): DiffFile {
const lines: DiffLine[] = []
const body = content.endsWith('\n') ? content.slice(0, -1) : content
if (body !== '') {
let num = 1
for (const line of body.split('\n')) {
lines.push({ kind: 'add', text: line, oldNum: null, newNum: num })
num += 1
}
}
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
}
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
function displayPath(path: string): string {
if (path === '/dev/null') return path
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
return path
}
/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
function fileTag(file: DiffFile): string | null {
if (file.binary) return t('diffBinary')
if (file.oldPath === '/dev/null') return t('diffAdded')
if (file.newPath === '/dev/null') return t('diffDeleted')
const oldPath = displayPath(file.oldPath)
const newPath = displayPath(file.newPath)
if (oldPath !== newPath) return t('diffRenamed')
return null
}
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
const MAX_DIFF_ROWS = 500
export interface DiffViewProps {
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
diff: string
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
untrackedPath?: string
untrackedContent?: string
}
export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) {
const parsed = useMemo<ParsedDiff>(() => {
if (untrackedPath !== undefined) {
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
}
return parseUnifiedDiff(diff)
}, [diff, untrackedPath, untrackedContent])
const [expanded, setExpanded] = useState(false)
// Flatten into display rows so the cap can slice a single list.
const rows = useMemo(() => {
const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
parsed.files.forEach((file, fileIndex) => {
out.push({ key: `f${fileIndex}`, file, type: 'path' })
if (file.binary) return
file.hunks.forEach((hunk, hunkIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk })
hunk.lines.forEach((line, lineIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line })
})
})
})
return out
}, [parsed])
const hidden = rows.length - MAX_DIFF_ROWS
const capped = hidden > 0 && !expanded
const headLines = Math.ceil(MAX_DIFF_ROWS / 2)
const tailLines = MAX_DIFF_ROWS - headLines
const head = capped ? rows.slice(0, headLines) : rows
const tail = capped ? rows.slice(rows.length - tailLines) : []
if (rows.length === 0) return null
const renderRow = (row: (typeof rows)[number]): ReactNode => {
if (row.type === 'path') {
const tag = fileTag(row.file)
const from = displayPath(row.file.oldPath)
const to = displayPath(row.file.newPath)
return (
<div key={row.key} className={css.gitDiffFile}>
<span className={css.gitDiffFilePath}>{to}</span>
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
</div>
)
}
if (row.type === 'hunk') {
const hunk = row.hunk!
return (
<div key={row.key} className={css.gitDiffHunk}>
<span className={css.gitDiffHunkHeader}>@@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@</span>
{hunk.header !== '' && <span className={css.gitDiffHunkSection}>{hunk.header}</span>}
</div>
)
}
const line = row.line!
const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
return (
<div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
{line.kind === 'meta'
? <span className={css.gitDiffMetaText}>{line.text}</span>
: (
<>
<span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
<span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
<span className={css.gitDiffCode}>{line.text}</span>
</>
)}
</div>
)
}
return (
<div className={css.gitDiff}>
{head.map(renderRow)}
{hidden > 0 && (
<button type="button" className={css.gitDiffExpand} aria-expanded={expanded} onClick={() => { setExpanded(value => !value) }}>
{expanded ? t('diffCollapse') : t('diffExpand', { count: hidden })}
</button>
)}
{tail.map(renderRow)}
</div>
)
}
+325 -56
View File
@@ -1,19 +1,23 @@
/**
* The source-control panel: status list (staged vs unstaged), per-file diff
* through the shared DiffBlock (old = HEAD/index content, new = worktree or
* index content), stage/unstage, commit with a message box, branch list with
* switch, and recent history. Refresh is manual + on mount/focus (no file
* watcher — KISS).
* The source-control panel: status list (staged vs unstaged), stage/unstage,
* commit with a message box, branch switch, and a VSCode-like history — rows
* carry branch decorations, author and relative time. Clicking a changed
* file or a history row opens a dedicated diff TAB (see {@link DiffTab}),
* placed below the git pane on first use. File rows and history rows open a
* right-click context menu with advanced operations (open in editor, discard,
* revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/
* focus (no file watcher — KISS).
*/
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { useCallback, useEffect, useState, type MouseEvent, type ReactNode } from 'react'
import {
DiffBlock, IconBranchOutline16, IconRefreshOutline16, IconTrashOutline16,
Input,
Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
IconTrashOutline16, Input, Menu, Modal, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import { relativeTo } from './paths.ts'
import { relativeTime, t } from './locales.ts'
import type { SidebarTab } from './state.ts'
import css from './sidebar.module.css'
/** The XY status letters a row badge shows (X = index, Y = worktree). */
@@ -25,8 +29,63 @@ function badgeOf(entry: GitStatusEntry): string {
return '?'
}
export function GitView(props: { scope: SessionScope }) {
const { scope } = props
/** Whether the entry carries STAGED (index) changes — the X letter is set. */
function isStagedEntry(entry: GitStatusEntry): boolean {
const index = entry.xy[0]
return index !== undefined && index !== ' ' && index !== '?'
}
/** Whether the entry carries UNSTAGED (worktree) changes — the Y letter is set
* (untracked `??` counts as unstaged: it is a worktree-only change). A file
* with both letters set ('MM') lands in BOTH sections. */
function isUnstagedEntry(entry: GitStatusEntry): boolean {
if (entry.xy === '??') return true
const worktree = entry.xy[1]
return worktree !== undefined && worktree !== ' ' && worktree !== '?'
}
/** Whether the entry is untracked (`??`): git diff never includes it. */
function isUntracked(entry: GitStatusEntry): boolean {
return badgeOf(entry) === '?'
}
/** The last path segment (tab title for a file's diff). */
function baseName(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
function refNames(refs: string): string[] {
return [...new Set(
refs
.split(',')
.map(ref => ref.trim())
.filter(ref => ref !== '')
.map(ref => (ref.includes(' -> ') ? ref.slice(ref.indexOf(' -> ') + 4) : ref))
.map(ref => (ref.startsWith('tag: ') ? ref.slice(5) : ref)),
)]
}
/** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
interface ConfirmState {
title: string
description: string
confirmLabel: string
onConfirm: () => Promise<unknown>
}
/** History batch size: the log loads lazily in pages so a long history never
* floods the panel at once (the end of the log is reached by paging). */
const LOG_BATCH = 20
export function GitView(props: {
scope: SessionScope
onOpenFile: (path: string) => void
/** Open a diff tab (the shell places it below the git pane on first use). */
onOpenDiff: (tab: SidebarTab) => void
}) {
const { scope, onOpenFile, onOpenDiff } = props
const [status, setStatus] = useState<GitStatusResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -35,12 +94,16 @@ export function GitView(props: { scope: SessionScope }) {
const [commitMsg, setCommitMsg] = useState('')
const [busy, setBusy] = useState(false)
const [commitError, setCommitError] = useState<string | null>(null)
/** Whether the history was fully paged (a batch shorter than LOG_BATCH). */
const [logEnded, setLogEnded] = useState(false)
const [logLoadingMore, setLogLoadingMore] = useState(false)
// Selected diff: path + side (staged) + the two text sides for DiffBlock.
const [diffPath, setDiffPath] = useState<string | null>(null)
const [diffStaged, setDiffStaged] = useState(false)
const [diffOld, setDiffOld] = useState<string | null>(null)
const [diffNew, setDiffNew] = useState('')
/** The open file-row context menu (cursor position for the portaled Menu). */
const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null)
/** The open history-row context menu. */
const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
/** The pending destructive action awaiting confirmation. */
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
const refresh = useCallback(async (): Promise<void> => {
setLoading(true)
@@ -49,11 +112,13 @@ export function GitView(props: { scope: SessionScope }) {
const [statusResult, branchResult, logResult] = await Promise.all([
api.gitStatus(scope),
api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
api.gitLog(scope).catch(() => [] as GitLogEntry[]),
// The first history page only; the rest arrives via "load more".
api.gitLog(scope, LOG_BATCH, 0).catch(() => [] as GitLogEntry[]),
])
setStatus(statusResult)
setBranchNames(branchResult.names)
setLogEntries(logResult)
setLogEnded(logResult.length < LOG_BATCH)
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
@@ -63,23 +128,39 @@ export function GitView(props: { scope: SessionScope }) {
useEffect(() => { void refresh() }, [refresh])
/** Load the diff sides for one entry: HEAD content vs worktree/index content. */
const selectDiff = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
setDiffPath(entry.path)
setDiffStaged(staged)
setDiffOld(null)
setDiffNew('')
const oldPromise = api.gitShow(scope, 'HEAD', entry.path)
.then(result => result.content)
.catch(() => null)
const newPromise = staged
? api.gitShow(scope, ':', entry.path).then(result => result.content ?? '').catch(() => '')
: api.fsRead(scope, entry.path)
.then(result => (result.kind === 'text' ? result.content : ''))
.catch(() => '')
const [oldText, newText] = await Promise.all([oldPromise, newPromise])
setDiffOld(oldText)
setDiffNew(newText)
/** Append the next history page (lazy: only when the user asks for more). */
const loadMoreLog = async (): Promise<void> => {
if (logLoadingMore || logEnded) return
setLogLoadingMore(true)
try {
const next = await api.gitLog(scope, LOG_BATCH, logEntries.length)
setLogEntries(entries => [...entries, ...next])
if (next.length < LOG_BATCH) setLogEnded(true)
} catch (reason) {
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
} finally {
setLogLoadingMore(false)
}
}
/** The diff tab for one changed file (one tab per path+side; same id = focused). */
const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
onOpenDiff({
id: `diff:w:${staged ? 's' : 'u'}:${entry.path}`,
type: 'diff',
title: baseName(entry.path),
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry) },
})
}
/** The diff tab for one commit (one tab per commit). */
const openCommitDiff = (entry: GitLogEntry): void => {
onOpenDiff({
id: `diff:c:${entry.hashFull}`,
type: 'diff',
title: `${entry.hash} ${entry.subject}`,
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject },
})
}
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
@@ -87,7 +168,6 @@ export function GitView(props: { scope: SessionScope }) {
try {
if (staged) await api.gitUnstage(scope, entry.path)
else await api.gitStage(scope, entry.path)
setDiffPath(null)
await refresh()
} finally {
setBusy(false)
@@ -99,7 +179,6 @@ export function GitView(props: { scope: SessionScope }) {
try {
if (staged) await api.gitUnstage(scope)
else await api.gitStage(scope)
setDiffPath(null)
await refresh()
} finally {
setBusy(false)
@@ -114,7 +193,6 @@ export function GitView(props: { scope: SessionScope }) {
try {
await api.gitCommit(scope, message)
setCommitMsg('')
setDiffPath(null)
await refresh()
} catch (reason) {
setCommitError(reason instanceof Error ? reason.message : String(reason))
@@ -129,7 +207,6 @@ export function GitView(props: { scope: SessionScope }) {
setCommitError(null)
try {
await api.gitCheckout(scope, branch)
setDiffPath(null)
await refresh()
} catch (reason) {
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
@@ -138,18 +215,51 @@ export function GitView(props: { scope: SessionScope }) {
}
}
const stagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) !== '?')
const unstagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) === '?')
/** Run one destructive operation after the confirm modal, then refresh. */
const runConfirmed = (confirmState: ConfirmState): void => {
setConfirm({ ...confirmState, onConfirm: async () => {
setBusy(true)
setCommitError(null)
try {
await confirmState.onConfirm()
await refresh()
} catch (reason) {
setCommitError(reason instanceof Error ? reason.message : String(reason))
} finally {
setBusy(false)
}
} })
}
/** Copy `text` to the clipboard (best-effort; no visual feedback needed — the menu closes). */
const copy = (text: string): void => {
void writeClipboard(text)
}
const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => {
event.preventDefault()
event.stopPropagation()
setFileMenu({ entry, staged, x: event.clientX, y: event.clientY })
}
const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
event.preventDefault()
event.stopPropagation()
setHistoryMenu({ entry, x: event.clientX, y: event.clientY })
}
const stagedEntries = (status?.entries ?? []).filter(isStagedEntry)
const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry)
const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
const selected = diffPath === entry.path && diffStaged === staged
return (
<div key={`${staged ? 's' : 'u'}:${entry.path}`} className={clsx(css.gitRow, selected && css.gitRowSelected)}>
<div key={`${staged ? 's' : 'u'}:${entry.path}`} className={css.gitRow}>
<button
type="button"
className={css.gitRowMain}
title={entry.path}
onClick={() => { void selectDiff(entry, staged) }}
onClick={() => { openWorktreeDiff(entry, staged) }}
onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
>
<span className={css.gitBadge}>{badgeOf(entry)}</span>
<span className={css.gitName}>{entry.path}</span>
@@ -224,15 +334,6 @@ export function GitView(props: { scope: SessionScope }) {
{unstagedEntries.map(entry => renderEntry(entry, false))}
</div>
{diffPath !== null && (
<div className={css.gitDiff}>
<DiffBlock
diffs={[{ path: diffPath, oldText: diffOld, newText: diffNew }]}
maxLines={300}
/>
</div>
)}
<div className={css.gitCommit}>
<Input
className={css.gitCommitInput}
@@ -258,12 +359,180 @@ export function GitView(props: { scope: SessionScope }) {
<div className={css.gitSection}>
<div className={css.gitSectionHeader}><span>{t('history')}</span></div>
{logEntries.map(entry => (
<div key={entry.hash} className={css.gitLogRow} title={`${entry.author} · ${entry.date}`}>
<span className={css.gitLogHash}>{entry.hash}</span>
<span className={css.gitLogSubject}>{entry.subject}</span>
<div
key={entry.hashFull}
role="button"
tabIndex={0}
className={css.gitLogRow}
title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
onClick={() => { openCommitDiff(entry) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openCommitDiff(entry)
}
}}
onContextMenu={(event) => { openHistoryMenu(event, entry) }}
>
<span className={css.gitLogLine1}>
<span className={css.gitLogHash}>{entry.hash}</span>
<span className={css.gitLogSubject}>{entry.subject}</span>
</span>
<span className={css.gitLogLine2}>
{refNames(entry.refs).map(ref => (
<span key={ref} className={css.gitLogRef}>{ref}</span>
))}
<span className={css.gitLogMeta}>{entry.author} · {relativeTime(entry.date)}</span>
</span>
</div>
))}
{!logEnded && (
<button
type="button"
className={css.gitLogMore}
disabled={logLoadingMore || busy}
onClick={() => { void loadMoreLog() }}
>
{logLoadingMore ? t('loading') : t('loadMore')}
</button>
)}
</div>
{/*
The one shared file-row context menu, positioned at the right-click
cursor (portal so the panel's overflow clip cannot crop it).
*/}
<Menu
open={fileMenu !== null}
onClose={() => { setFileMenu(null) }}
items={[
{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> },
fileMenu?.staged === true
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
: { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
...(fileMenu !== null && !isUntracked(fileMenu.entry)
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
: []),
{ type: 'separator', id: 'sep1' },
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
]}
onSelect={(id) => {
const target = fileMenu
if (target === null) return
setFileMenu(null)
if (id === 'open') {
onOpenFile(target.entry.path)
return
}
if (id === 'stage') {
void stageEntry(target.entry, target.staged)
return
}
if (id === 'discard') {
runConfirmed({
title: t('discardTitle'),
description: t('discardDesc', { path: target.entry.path }),
confirmLabel: t('discard'),
onConfirm: () => api.gitDiscard(scope, target.entry.path),
})
return
}
if (id === 'relative') {
copy(relativeTo(scope.cwd ?? '', target.entry.path))
return
}
if (id === 'absolute') copy(target.entry.path)
}}
portal
align="start"
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
anchor={<span />}
/>
{/* The shared history-row context menu. */}
<Menu
open={historyMenu !== null}
onClose={() => { setHistoryMenu(null) }}
items={[
{ id: 'view', label: t('viewCommitDiff') },
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
{ type: 'separator', id: 'sep2' },
{ id: 'revert', label: t('revertCommit'), danger: true },
{ id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
]}
onSelect={(id) => {
const target = historyMenu
if (target === null) return
setHistoryMenu(null)
if (id === 'view') {
openCommitDiff(target.entry)
return
}
if (id === 'copyShort') {
copy(target.entry.hash)
return
}
if (id === 'copyFull') {
copy(target.entry.hashFull)
return
}
if (id === 'copySubject') {
copy(target.entry.subject)
return
}
if (id === 'revert') {
runConfirmed({
title: t('revertTitle'),
description: t('revertDesc', { subject: target.entry.subject }),
confirmLabel: t('revertCommit'),
onConfirm: () => api.gitRevert(scope, target.entry.hashFull),
})
return
}
if (id === 'cherryPick') {
runConfirmed({
title: t('cherryPickTitle'),
description: t('cherryPickDesc', { subject: target.entry.subject }),
confirmLabel: t('cherryPickCommit'),
onConfirm: () => api.gitCherryPick(scope, target.entry.hashFull),
})
}
}}
portal
align="start"
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
anchor={<span />}
/>
{/* Destructive actions land here first: Cancel / Confirm. */}
<Modal
open={confirm !== null}
onClose={() => { setConfirm(null) }}
title={confirm?.title ?? ''}
closeLabel={t('cancel')}
footer={(
<>
<Button variant="outline" onClick={() => { setConfirm(null) }}>{t('cancel')}</Button>
<Button
variant="primary"
disabled={busy}
onClick={() => {
const pending = confirm
if (pending === null) return
setConfirm(null)
void pending.onConfirm()
}}
>
{confirm?.confirmLabel ?? ''}
</Button>
</>
)}
>
<p className={css.gitConfirmDesc}>{confirm?.description}</p>
</Modal>
</>
)}
</div>
+20
View File
@@ -128,6 +128,13 @@ export function SideCardSection({ store }: SideCardSectionProps) {
void commit({ autoOpenSubagent: next }).then(outcome => applyOutcome(previous, outcome))
}
const onToggleTools = (next: boolean): void => {
const previous = prefs
setPrefs({ ...previous, agentTerminalTools: next })
setError(null)
void commit({ agentTerminalTools: next }).then(outcome => applyOutcome(previous, outcome))
}
const commitWidth = (): void => {
const parsed = Number(widthDraft)
if (!Number.isFinite(parsed)) {
@@ -193,6 +200,19 @@ export function SideCardSection({ store }: SideCardSectionProps) {
onChange={event => { onToggleSubagent(event.currentTarget.checked) }}
/>
</label>
<label className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsToolsTitle')}</span>
<span className={css.desc}>{t('settingsToolsDesc')}</span>
</span>
<input
type="checkbox"
className={css.toggle}
checked={prefs.agentTerminalTools}
aria-label={t('settingsToolsTitle')}
onChange={event => { onToggleTools(event.currentTarget.checked) }}
/>
</label>
{error !== null && (
<div className={css.error} role="alert">
{error}
+98 -13
View File
@@ -15,7 +15,8 @@ import clsx from 'clsx'
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
import {
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, openTab,
reconcileAgentTerminals,
resizeSplit, setWidth, toggleExpanded, togglePanel,
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
@@ -28,6 +29,7 @@ import { ExplorerView } from './ExplorerView.tsx'
import { EditorView } from './EditorView.tsx'
import { TerminalView } from './TerminalView.tsx'
import { GitView } from './GitView.tsx'
import { DiffTab } from './DiffTab.tsx'
import { SubagentView } from './SubagentView.tsx'
import { detectNewDirectSubagent } from './subagent-detect.ts'
import { t } from './locales.ts'
@@ -35,6 +37,10 @@ import { api } from './api.ts'
import { openSidebarFile } from './intercept.tsx'
import css from './sidebar.module.css'
/** How many consecutive reconnect failures stop the agent-terminals push loop
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
const FAILURE_LIMIT = 3
/** Render the content of one tab (dispatched by type). */
function TabContent(props: {
tab: SidebarTab
@@ -49,8 +55,10 @@ function TabContent(props: {
visible: boolean
/** Fired before a topology node jumps to its child session (see Sidebar). */
onSubagentJump: (childSessionId: string) => void
/** Open a diff tab from the git panel (placement handled by the store). */
onOpenDiff: (tab: SidebarTab) => void
}) {
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump } = props
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const scope = { sessionId, cwd }
switch (tab.type) {
case 'explorer':
@@ -65,7 +73,17 @@ function TabContent(props: {
/>
)
case 'git':
return <GitView scope={scope} />
return (
<GitView
scope={scope}
onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }}
onOpenDiff={onOpenDiff}
/>
)
case 'diff':
return tab.diff === undefined
? null
: <DiffTab sessionId={sessionId} cwd={cwd} diff={tab.diff} />
case 'terminal':
return <TerminalView scope={scope} tabId={tab.id} store={store} />
case 'editor':
@@ -82,19 +100,21 @@ function TabContent(props: {
}
}
/** The + menu options for the current state (terminal cap applied). */
/** The + menu options for the current state (UI-tab terminal cap applied).
* Agent-owned terminals (`agent:` tabs) are the model's, not the user's:
* they never count toward the per-session UI cap. */
function buildNewTabOptions(state: SidebarState): NewTabOption[] {
const terminalCount = allLeaves(state.splits)
const uiTerminalCount = allLeaves(state.splits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal').length
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
return [
{ id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) },
{ id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) },
{ id: 'subagent', label: t('openSubagent'), icon: tabTypeIcon('subagent', 16) },
{
id: 'terminal',
label: terminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
disabled: terminalCount >= TERMINAL_LIMIT,
label: uiTerminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
disabled: uiTerminalCount >= TERMINAL_LIMIT,
icon: tabTypeIcon('terminal', 16),
},
]
@@ -137,6 +157,58 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
/**
* Agent terminals push: subscribe to the host's live list of agent-owned
* terminals for this session (created by the model through the
* `terminal_create` tool). The host pushes a JSON array on every
* create / close / exit; the sidebar reconciles the list into tabs
* (id `agent:<uuid>`, title from the agent). A disconnected socket
* retries with a short backoff so a refresh or transient drop reattaches
* the same shell without losing the agent's work — capped like the
* terminal view's own reconnect loop, so a refused endpoint never spins
* forever (the next session switch restarts the loop).
*/
useEffect(() => {
if (sessionId === undefined) return
let socket: WebSocket | null = null
let retry: number | undefined
let closed = false
let failures = 0
const connect = (): void => {
if (closed) return
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = new URLSearchParams({ sessionId }).toString()
socket = new WebSocket(url.toString())
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
try {
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
if (!Array.isArray(list)) return
store.reduce(s => reconcileAgentTerminals(s, list))
} catch {
// Malformed push: ignore (the next push will reconcile).
}
}
socket.onclose = () => {
if (closed) return
failures += 1
if (failures >= FAILURE_LIMIT) {
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
return
}
retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => { socket?.close() }
}
connect()
return () => {
closed = true
window.clearTimeout(retry)
socket?.close()
}
}, [sessionId, store])
/**
* Subagent auto-activation: the moment the current conversation spawns its
* FIRST direct subagent (a 0 → N transition on the list feed) and the
@@ -212,12 +284,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// A closed terminal releases its pty immediately — including when its
// socket is mid-reconnect, where the unmount close frame never reaches
// the host and the process would hold the quota until the grace ends.
// Agent terminals (tabId `agent:<uuid>`) close through a different
// host route: the WS close frame is the primary path (sent by
// TerminalView on unmount), and the agent-pty.close HTTP route is the
// fallback when the WS is down.
const current = store.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
store.reduce(s => closeTab(s, paneId, tabId))
if (sessionId !== undefined && tab?.type === 'terminal') {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
if (tab?.type === 'terminal') {
if (isAgentTabId(tabId)) {
const uuid = agentUuidOf(tabId)
void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
} else if (sessionId !== undefined) {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
}
}
},
activateTab: (paneId, tabId) => {
@@ -297,7 +378,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
return openTab(s, { id: 'subagent', type: 'subagent', title: t('subagent') })
}
if (optionId === 'terminal') {
const count = allLeaves(s.splits).flatMap(leaf => leaf.tabs).filter(tab => tab.type === 'terminal').length
const count = allLeaves(s.splits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
if (count >= TERMINAL_LIMIT) return s
const tab: SidebarTab = {
id: `terminal:${s.nextTerminal}`,
@@ -320,9 +403,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* Render one tab's content. `active` (from the workbench) tells whether
* this tab is the active one in its pane; combined with the panel's
* open/closed state it gates live views (the Subagent topology pauses its
* polling while the page is not actually visible).
* polling while the page is not actually visible). The pane id travels
* with the tab so diff tabs can split below their source pane.
*/
const renderTab = (tab: SidebarTab, active: boolean) => (
const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => (
<TabContent
tab={tab}
sessionId={sessionId}
@@ -334,6 +418,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
store={store}
visible={state.panelOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
/>
)
+28 -7
View File
@@ -7,6 +7,17 @@
* a failed pty spawn) stops the loop and shows the reason with a manual
* retry, and repeated unreasoned failures surface the close code after three
* attempts, so the banner never spins forever.
*
* Two attach modes share one upgrade endpoint:
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
* terminal closes when the user closes the tab); a bare socket drop
* leaves the pty alive (the agent owns the lifetime).
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
* frame schedules a 0-ms close; a bare socket drop gets the host's
* reconnect grace.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from 'xterm'
@@ -14,7 +25,7 @@ import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import type { SidebarStore } from './state.ts'
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
import css from './sidebar.module.css'
@@ -97,14 +108,21 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
let failures = 0
const wsUrl = (): string => {
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
const url = new URL('/sidebar/ws/terminal', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
// Agent terminals attach by uuid (the host looks them up in the agent
// pty registry); UI-tab terminals attach by sessionId+tab (the host
// uses the UI-tab pty manager). Same upgrade endpoint, different query.
if (isAgentTabId(tabId)) {
url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
} else {
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
url.search = params.toString()
}
// Same construction the app's own downlink WebSockets use (new URL
// over location.origin + protocol swap): whatever the environment
// does to the app's websockets applies identically here.
const url = new URL('/sidebar/ws/terminal', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = params.toString()
return url.toString()
}
@@ -175,11 +193,14 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
schemeSub()
inputSub.dispose()
// The close frame tells the host the owning tab is GONE (immediate
// quota release). A bare unmount — conversation switch, re-render,
// pty release). A bare unmount — conversation switch, re-render,
// page unload — leaves the tab open, so the socket drop alone hands
// the process to the host's reconnect grace: switching back or
// refreshing reattaches the SAME shell instead of respawning one.
// (The host respawns on its own when the authoritative cwd changed.)
// Agent terminals follow the same rule: a close frame kills the pty
// (the user closed the sidebar tab); a bare socket drop leaves it
// alive (the agent owns the lifetime).
if (!store.tabOpen(scope.sessionId, tabId)
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'close' }))
+27 -4
View File
@@ -40,10 +40,16 @@ export interface GitStatusResult {
/** One git log row. */
export interface GitLogEntry {
/** Short hash (7+ chars, display). */
hash: string
/** Full 40-char hash (advanced operations). */
hashFull: string
subject: string
author: string
/** ISO 8601 author date (`%ai`). */
date: string
/** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
refs: string
}
/** Text read result. */
@@ -100,8 +106,6 @@ export const api = {
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
gitShow: (scope: SessionScope, rev: string, path: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', scopePayload(scope, { rev, path }), signal),
gitStage: (scope: SessionScope, path?: string) =>
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
gitUnstage: (scope: SessionScope, path?: string) =>
@@ -112,13 +116,32 @@ export const api = {
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
gitCheckout: (scope: SessionScope, branch: string) =>
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
gitLog: (scope: SessionScope, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', scopePayload(scope, {}), signal),
/** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', scopePayload(scope, {
...(count !== undefined ? { count } : {}),
...(skip !== undefined ? { skip } : {}),
}), signal),
/** Full patch text of one commit (diff display for the history rows). */
gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) =>
call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal),
/** Discard the worktree changes of one file (the index is untouched). */
gitDiscard: (scope: SessionScope, path: string) =>
call<{ ok: true }>('git.discard', scopePayload(scope, { path })),
/** Revert one commit onto the current branch. */
gitRevert: (scope: SessionScope, hash: string) =>
call<{ ok: true }>('git.revert', scopePayload(scope, { hash })),
/** Cherry-pick one commit onto the current branch. */
gitCherryPick: (scope: SessionScope, hash: string) =>
call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })),
/** Release a terminal's process immediately (tab closed; the WS close frame
* may be unreachable while the socket is down, so the host also accepts
* this explicit route). */
ptyClose: (scope: SessionScope, tab: string) =>
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number }>('settings.get', {}),
+10
View File
@@ -22,6 +22,15 @@ export const IconTerminalOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** Diff glyph in the app's outline style: a file frame with a plus and a minus row. */
export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="1.5" width="13" height="13" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 5h3M5.5 3.5v3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M9.5 12.5h2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
/** The one icon per tab type (shared by tabs, the + menu, and pane cards). */
export function tabTypeIcon(type: TabType, size = 16): ReactNode {
switch (type) {
@@ -30,5 +39,6 @@ export function tabTypeIcon(type: TabType, size = 16): ReactNode {
case 'terminal': return <IconTerminalOutline16 size={size} />
case 'subagent': return <IconThinkOutline16 size={size} />
case 'editor': return <IconCodeOutline16 size={size} />
case 'diff': return <IconDiffOutline16 size={size} />
}
}
+74
View File
@@ -54,6 +54,34 @@ const zh = {
changes: '变更',
staged: '已暂存',
unstaged: '未暂存',
cancel: '取消',
diffEmpty: '没有文本差异',
diffLoadError: '加载差异失败',
diffBinary: '二进制',
diffAdded: '新增',
diffDeleted: '删除',
diffRenamed: '重命名',
diffExpand: '展开其余 {count} 行',
diffCollapse: '收起',
discard: '放弃更改',
discardTitle: '放弃更改',
discardDesc: '将丢弃「{path}」的工作区修改(不可恢复)。',
viewCommitDiff: '查看提交差异',
copyShortHash: '复制短哈希',
copyFullHash: '复制完整哈希',
copySubject: '复制提交信息',
revertCommit: '还原此提交',
revertTitle: '还原此提交',
revertDesc: '将在当前分支创建一个反转「{subject}」的新提交。',
cherryPickCommit: '捡取此提交',
cherryPickTitle: '捡取此提交',
cherryPickDesc: '将「{subject}」的更改应用到当前分支。',
timeJustNow: '刚刚',
timeMinutesAgo: '{n} 分钟前',
timeHoursAgo: '{n} 小时前',
timeYesterday: '昨天',
loadMore: '加载更多',
historyLoadError: '加载更多历史失败',
produced: '本次产出',
producedOpen: '在侧边栏中打开',
disconnected: '终端连接断开,重连中…',
@@ -90,6 +118,8 @@ const zh = {
zoomHint: 'Alt + 滚轮',
settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
subagent: '子代理',
openSubagent: '子代理',
subagentMainAgent: '主代理',
@@ -156,6 +186,34 @@ const en: Record<keyof typeof zh, string> = {
changes: 'Changes',
staged: 'Staged',
unstaged: 'Unstaged',
cancel: 'Cancel',
diffEmpty: 'No text changes',
diffLoadError: 'Failed to load diff',
diffBinary: 'Binary',
diffAdded: 'Added',
diffDeleted: 'Deleted',
diffRenamed: 'Renamed',
diffExpand: 'Expand {count} more rows',
diffCollapse: 'Collapse',
discard: 'Discard changes',
discardTitle: 'Discard changes',
discardDesc: 'This discards the worktree changes of "{path}" (not recoverable).',
viewCommitDiff: 'View commit diff',
copyShortHash: 'Copy short hash',
copyFullHash: 'Copy full hash',
copySubject: 'Copy subject',
revertCommit: 'Revert commit',
revertTitle: 'Revert commit',
revertDesc: 'Create a new commit on the current branch that reverts "{subject}".',
cherryPickCommit: 'Cherry-pick commit',
cherryPickTitle: 'Cherry-pick commit',
cherryPickDesc: 'Apply the changes of "{subject}" to the current branch.',
timeJustNow: 'just now',
timeMinutesAgo: '{n} min ago',
timeHoursAgo: '{n} h ago',
timeYesterday: 'yesterday',
loadMore: 'Load more',
historyLoadError: 'Failed to load more history',
produced: 'Produced',
producedOpen: 'Open in sidebar',
disconnected: 'Terminal disconnected, reconnecting…',
@@ -192,6 +250,8 @@ const en: Record<keyof typeof zh, string> = {
zoomHint: 'Alt + wheel',
settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears',
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
settingsToolsTitle: 'Inject terminal tools for the model',
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
subagent: 'Subagents',
openSubagent: 'Subagents',
subagentMainAgent: 'Main agent',
@@ -229,3 +289,17 @@ export function t(key: CopyKey, params?: Record<string, string | number>): strin
export function isZh(): boolean {
return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
}
/** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */
export function relativeTime(iso: string): string {
const then = Date.parse(iso)
if (Number.isNaN(then)) return iso
const seconds = Math.floor((Date.now() - then) / 1000)
if (seconds < 60) return t('timeJustNow')
if (seconds < 3600) return t('timeMinutesAgo', { n: Math.floor(seconds / 60) })
if (seconds < 86400) return t('timeHoursAgo', { n: Math.floor(seconds / 3600) })
if (seconds < 172800) return t('timeYesterday')
const date = new Date(then)
const pad = (value: number): string => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
+3
View File
@@ -41,6 +41,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
? record.autoOpenSubagent
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
}
}
+232 -2
View File
@@ -122,6 +122,7 @@
.panelBody {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
}
@@ -1051,9 +1052,13 @@
.git {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
/* Tab pages never scroll horizontally: content wraps or truncates to the
panel width instead of panning sideways. */
overflow-x: hidden;
}
.gitHeader {
@@ -1189,6 +1194,167 @@
padding: 8px;
}
/* The diff tab (a change opened from the git panel, like VSCode's diff
editor): header + scrollable diff body, no horizontal panning. */
.gitDiffTab {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
.gitDiffTabHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.gitDiffTabTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
}
/* One changed file's section: path header, hunk headers, aligned lines. */
.gitDiffFile {
display: flex;
align-items: baseline;
gap: 6px;
padding: 8px 2px 2px;
}
.gitDiffFilePath {
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gitDiffFileOld {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 40%;
}
.gitDiffFileTag {
flex: none;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-secondary);
}
.gitDiffHunk {
display: flex;
gap: 8px;
padding: 3px 2px;
font: var(--dsw-font-markdown-code-block-small);
color: var(--dsw-alias-label-tertiary);
}
.gitDiffHunkHeader {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.gitDiffHunkSection {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* One aligned diff line: old gutter / new gutter / code. Lines WRAP instead
of scrolling horizontally (the tab-page rule: fill the width adaptively),
with the long-token fallback breaking anywhere a space never comes. */
.gitDiffLine {
display: flex;
align-items: stretch;
font: var(--dsw-font-markdown-code-block-small);
line-height: 20px;
white-space: pre-wrap;
overflow-wrap: anywhere;
min-width: 0;
}
.gitDiffNum {
flex: none;
width: 36px;
padding-right: 8px;
text-align: right;
color: var(--dsw-alias-label-tertiary);
user-select: none;
}
.gitDiffCode {
flex: 1;
min-width: 0;
overflow: visible;
}
.gitDiffCtx {
color: var(--dsw-alias-label-primary);
}
.gitDiffDel {
color: var(--dsw-alias-state-error-primary);
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
}
.gitDiffAdd {
color: var(--dsw-alias-state-success-primary);
background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent);
}
.gitDiffMeta {
padding-left: 2px;
}
.gitDiffMetaText {
font: var(--dsw-font-xxxs-11);
font-style: italic;
color: var(--dsw-alias-label-tertiary);
}
.gitDiffExpand {
display: block;
width: 100%;
margin: 4px 0;
border: none;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-brand-primary);
cursor: pointer;
text-align: center;
}
.gitDiffExpand:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitConfirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
}
.gitCommit {
display: flex;
gap: 6px;
@@ -1223,12 +1389,26 @@
cursor: default;
}
/* A history row: line 1 = hash + subject, line 2 = ref badges + author/date
(badges live on the second line so they never crowd the subject). */
.gitLogRow {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 12px;
cursor: pointer;
border-radius: 8px;
}
.gitLogRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitLogLine1 {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 12px;
font: var(--dsw-font-xxs-12);
min-width: 0;
}
.gitLogHash {
@@ -1237,15 +1417,62 @@
color: var(--dsw-alias-label-tertiary);
}
.gitLogLine2 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.gitLogRef {
flex: none;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-brand-primary);
white-space: nowrap;
}
.gitLogSubject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitLogMeta {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.gitLogMore {
display: block;
width: calc(100% - 24px);
margin: 4px 12px 8px;
padding: 6px 0;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.gitLogMore:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.gitLogMore:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Intercepted produced-files row ────────────────────────────────────── */
.producedRow {
@@ -1307,6 +1534,9 @@
.gitRowMain:focus-visible,
.gitLink:focus-visible,
.gitCommitButton:focus-visible,
.gitLogRow:focus-visible,
.gitLogMore:focus-visible,
.gitDiffExpand:focus-visible,
.terminalRetry:focus-visible,
.editorModeButton:focus-visible,
.editorDownloadLink:focus-visible,
+4 -4
View File
@@ -122,7 +122,7 @@ function LeafView(props: {
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean) => ReactNode
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
}) {
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
const [dropZone, setDropZone] = useState<DropZone | null>(null)
@@ -196,7 +196,7 @@ function LeafView(props: {
key={tab.id}
className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
>
{renderTab(tab, tab.id === activeTab?.id)}
{renderTab(tab, tab.id === activeTab?.id, leaf.id)}
</div>
))}
</div>
@@ -214,7 +214,7 @@ function NodeView(props: {
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean) => ReactNode
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
}) {
const { node, state, newTabOptions, actions, onNewTab, renderTab } = props
if (node.kind === 'leaf') {
@@ -264,7 +264,7 @@ export function Workbench(props: {
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean) => ReactNode
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
}) {
const { state, newTabOptions, actions, onNewTab, renderTab } = props
return (
+125 -4
View File
@@ -11,14 +11,21 @@
*/
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent'
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent' | 'diff'
/** One open tab. `path` carries the file (editor) or is absent (explorer/git). */
/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
export type SidebarDiffRef =
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
| { kind: 'commit'; hash: string; hashFull: string; subject: string }
/** One open tab. `path` carries the file (editor) or is absent (explorer/git);
* `diff` carries the change a diff tab shows. */
export interface SidebarTab {
id: string
type: TabType
title: string
path?: string
diff?: SidebarDiffRef
}
/** A tab group. */
@@ -320,6 +327,13 @@ export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
}
if (tab.type === 'diff') {
// One tab per change: the same path+side (or commit) focuses its instance.
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(candidate => candidate.type === 'diff' && candidate.id === tab.id)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
}
return {
...state,
activePane: targetId,
@@ -358,6 +372,38 @@ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState
return { ...state, splits: splitLeafAt(state.splits, paneId, dir) }
}
/**
* Open a diff tab the VSCode way: an existing instance of the same change is
* focused wherever it lives; otherwise the tab joins the first pane that
* already holds diff tabs (diff panes are sticky — repeated clicks stack
* there); on the FIRST diff of a layout the source pane splits vertically so
* the diff lands in a fresh pane below it ("默认在下半栏新增一个").
* @returns the new state, with the diff pane active.
*/
export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
const existingLeaf = leafWithTab(state.splits, tab.id)
if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id)
const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
if (diffLeaf !== undefined) {
return {
...state,
activePane: diffLeaf.id,
splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
}
}
// First diff: split the source pane, the diff tab in the new LOWER leaf.
// (A stale sourcePaneId — its pane closed meanwhile — degrades to the
// regular open path instead of dropping the tab into an orphaned leaf.)
if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
return openTab(state, tab)
}
const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
return { ...state, splits: result.node, activePane: result.leafId }
}
/** Toggle the panel open/closed (opening restores the previous layout). */
export function togglePanel(state: SidebarState): SidebarState {
return { ...state, panelOpen: !state.panelOpen }
@@ -396,6 +442,70 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del
}
}
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
export const AGENT_TAB_PREFIX = 'agent:'
/** Whether a tab id refers to an agent-owned terminal. */
export function isAgentTabId(tabId: string): boolean {
return tabId.startsWith(AGENT_TAB_PREFIX)
}
/** Extract the agent terminal uuid from an `agent:<uuid>` tab id. */
export function agentUuidOf(tabId: string): string {
return tabId.slice(AGENT_TAB_PREFIX.length)
}
/** Build the sidebar tab id for one agent terminal uuid. */
export function agentTabId(uuid: string): string {
return `${AGENT_TAB_PREFIX}${uuid}`
}
/**
* Reconcile the sidebar's agent-terminal tabs with the host's live list.
* The host pushes the current list of agent terminals (created by the model
* through the `terminal_create` tool) over a dedicated WebSocket; this
* reducer mirrors that list into tabs: new uuids get a tab, vanished uuids
* lose theirs. The agent owns the lifetime — the user closing a tab sends a
* WS close frame that kills the pty, which fires a change, which converges
* the view. Idempotent: a no-op when the lists already match.
* @param state - the current per-session sidebar state.
* @param agentTerminals - the live agent terminal snapshots from the host.
* @returns the next state (or the same reference if no change was needed).
*/
export function reconcileAgentTerminals(
state: SidebarState,
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
): SidebarState {
const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid))
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)))
if (toAdd.length === 0 && toRemove.length === 0) return state
// Remove tabs whose uuids vanished from the server list (the agent closed
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
let splits = state.splits
for (const tab of toRemove) {
const leaf = leafWithTab(splits, tab.id)
if (leaf !== undefined) {
splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
}
}
// Add tabs for new uuids (the agent created a terminal). They land in the
// active pane via openTab; the next reconcile is a no-op for them.
let next: SidebarState = { ...state, splits }
for (const terminal of toAdd) {
const tab: SidebarTab = {
id: agentTabId(terminal.uuid),
type: 'terminal',
title: terminal.title,
}
next = openTab(next, tab)
}
return next
}
// ── The per-session store ──────────────────────────────────────────────────
const STORAGE_PREFIX = 'dsh-sidebar:v1'
@@ -499,10 +609,19 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
if (record.kind === 'leaf') {
if (typeof record.id !== 'string' || !Array.isArray(record.tabs)) return undefined
const tabs: SidebarTab[] = []
let droppedDiff = false
for (const tab of record.tabs) {
if (tab === null || typeof tab !== 'object') return undefined
const candidate = tab as Record<string, unknown>
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
// Diff tabs are ephemeral by nature (like VSCode's diff editors):
// they never survive a reload, so a stale ref (e.g. a wrong staged
// side persisted by an older build) cannot resurface as a dead tab
// showing "no text changes" after every refresh.
if (candidate.type === 'diff') {
droppedDiff = true
continue
}
if (
candidate.type !== 'explorer' && candidate.type !== 'git'
&& candidate.type !== 'editor' && candidate.type !== 'terminal'
@@ -518,8 +637,10 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
})
}
const active = typeof record.active === 'string' ? record.active : null
if (active !== null && !tabs.some(tab => tab.id === active)) return undefined
return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active }
// An active pointer into a dropped diff tab is expected after the drop;
// any other missing active is structural corruption → reset the state.
if (active !== null && !tabs.some(tab => tab.id === active) && !droppedDiff) return undefined
return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active: active !== null && tabs.some(tab => tab.id === active) ? active : null }
}
if (record.kind === 'split') {
if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
+1
View File
@@ -79,4 +79,5 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
openByDefault: z.boolean().default(true),
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
})
+26
View File
@@ -274,6 +274,31 @@ export interface SidebarSettingsService {
update(ns: string, patch: object, expectedRevision?: number): Promise<void>
}
/**
* The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRegistry).
* The host half registers model-facing tools here; the registry attaches the
* returned disposer to the contributing fiber so unloading unregisters them.
*/
export interface SidebarToolsService {
/** Register one tool definition (raw JSON-Schema or defineTool-sugar form). */
register(tool: unknown): () => void
}
/**
* The agent face a tool sees on `exec.agent` (mirror of @deepseek-ai/dsh-agent's
* Agent). Only the slices the terminal tools touch are restated: the live
* session identity and its header cwd, both readonly.
*/
export interface SidebarAgent {
/** The live session identity shared with the session log. */
readonly id: string
/** The live session this agent drives. */
readonly session: {
/** The session's header (validated cwd, lineage metadata). */
readonly header: { readonly cwd?: string }
}
}
declare module 'cordis' {
interface Context {
httpServer: SidebarHttpServer
@@ -283,6 +308,7 @@ declare module 'cordis' {
slots: SidebarSlotsService
settings: SidebarSettingsService
invariants: SidebarInvariantsService
tools: SidebarToolsService
/**
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
* activation; its returned cleanup runs at disposal.
+50 -6
View File
@@ -27,10 +27,16 @@ export interface GitStatusResult {
/** One `git log` row. */
export interface GitLogEntry {
/** Short hash (7+ chars, display). */
hash: string
/** Full 40-char hash (advanced operations: revert / cherry-pick). */
hashFull: string
subject: string
author: string
/** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */
date: string
/** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
refs: string
}
/** One git failure (stderr text as the message). */
@@ -65,14 +71,21 @@ export function parsePorcelainZ(output: string): GitStatusEntry[] {
return entries
}
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai` rows. */
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
export function parseLogLines(output: string): GitLogEntry[] {
const rows: GitLogEntry[] = []
for (const line of output.split('\n')) {
if (line === '') continue
const [hash, subject, author, date] = line.split('\x1f')
const [hash, subject, author, date, hashFull, refs] = line.split('\x1f')
if (hash === undefined || subject === undefined) continue
rows.push({ hash, subject, author: author ?? '', date: date ?? '' })
rows.push({
hash,
subject,
author: author ?? '',
date: date ?? '',
hashFull: hashFull ?? hash,
refs: refs ?? '',
})
}
return rows
}
@@ -118,6 +131,12 @@ export async function isGitRepo(cwd: string): Promise<boolean> {
}
}
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
export async function repoRoot(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
return out.trim()
}
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
export async function currentBranch(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
@@ -173,9 +192,12 @@ export async function checkout(cwd: string, branch: string): Promise<void> {
await runGit(cwd, ['checkout', branch])
}
/** Recent commit history (newest first). */
export async function log(cwd: string, count = 30): Promise<GitLogEntry[]> {
const raw = await runGit(cwd, ['log', `-n ${count}`, '--pretty=format:%h%x1f%s%x1f%an%x1f%ai'])
/** Recent commit history (newest first), lazily pageable via skip/count. */
export async function log(cwd: string, count = 30, skip = 0): Promise<GitLogEntry[]> {
const raw = await runGit(cwd, [
'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
'--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
])
return parseLogLines(raw)
}
@@ -190,3 +212,25 @@ export async function show(cwd: string, rev: string, path: string): Promise<stri
return null
}
}
/** Full patch text of one commit (`git show` with the commit header suppressed).
* Merge commits show their diff against the first parent (`-m --first-parent`
* is a no-op for regular commits), so a history click always has content. */
export async function commitDiff(cwd: string, hash: string): Promise<string> {
return runGit(cwd, ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
}
/** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
export async function discard(cwd: string, path: string): Promise<void> {
await runGit(cwd, ['checkout', '--', path])
}
/** Revert one commit onto the current branch with an auto-generated message. */
export async function revert(cwd: string, hash: string): Promise<void> {
await runGit(cwd, ['revert', '--no-edit', hash])
}
/** Cherry-pick one commit onto the current branch. */
export async function cherryPick(cwd: string, hash: string): Promise<void> {
await runGit(cwd, ['cherry-pick', hash])
}
+255 -14
View File
@@ -12,7 +12,7 @@
* processes are keyed by session.
*/
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, extname } from 'node:path'
import { basename, dirname, extname, isAbsolute, join } from 'node:path'
import type { IncomingMessage } from 'node:http'
import { WebSocket, WebSocketServer } from 'ws'
import type { Context } from './context-types.ts'
@@ -23,12 +23,15 @@ import {
SIDEBAR_PREFS_NS,
type ResolvedSidebarConfig,
type SidebarConfig,
type SidebarPrefs,
} from './config.ts'
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { isTrustedApiRequest } from './trust-fence.ts'
import * as git from './git.ts'
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
import { registerTools } from './tools.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
export { Config }
@@ -37,8 +40,8 @@ export type { SidebarConfig, ResolvedSidebarConfig }
/** Plugin identity for cordis.yml rows. */
export const name = 'dsh-better-sidebar'
/** Services required before mounting: the webserver routes, the session store, and the loader's connection row. */
export const inject = ['httpServer', 'sessions', 'loader']
/** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */
export const inject = ['httpServer', 'sessions', 'loader', 'tools']
/** Content types for the media route, by extension. */
const MEDIA_TYPES: Record<string, string> = {
@@ -94,6 +97,19 @@ function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): stri
return process.cwd()
}
/**
* Resolve a path that a git command reported — `git status`/`git diff`
* print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
* session cwd (a session inside a subdirectory of a repository). Absolute
* paths pass through; relative ones join the repo root (falling back to the
* cwd when the root cannot be resolved, e.g. a bare directory).
*/
async function resolveGitPath(cwd: string, raw: string): Promise<string> {
if (isAbsolute(raw)) return requireAbsolute(raw)
const root = await git.repoRoot(cwd).catch(() => cwd)
return requireAbsolute(join(root, raw))
}
/** Text read of a file with the size cap; binary detection via NUL probe. */
async function readText(path: string, readLimit: number): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> {
const info = await stat(path).catch((error: unknown) => {
@@ -135,10 +151,11 @@ export interface SidebarSettingsFace {
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
}
/** Build the API method table bound to the plugin context, pty manager, and resolved config. */
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
function buildApi(
ctx: Context,
ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry,
resolved: ResolvedSidebarConfig,
getSettings: () => SidebarSettingsFace | undefined,
): Record<string, ApiMethod> {
@@ -161,7 +178,9 @@ function buildApi(
},
'fs.read': async (payload) => {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
// Relative paths are git-derived (status/diff report repo-root-relative
// names; the untracked diff view reads the file through this route).
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
const { content, truncated, binary, size } = await readText(path, resolved.readLimit)
if (binary) return { kind: 'binary', size, truncated }
return { kind: 'text', content, truncated }
@@ -188,7 +207,7 @@ function buildApi(
'git.diff': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown; staged?: unknown }
const path = record.path === undefined ? undefined : requireAbsolute(requireString(payload, 'path'))
const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path'))
return { diff: await git.diff(cwd, path, record.staged === true) }
},
'git.stage': async (payload) => {
@@ -222,11 +241,37 @@ function buildApi(
},
'git.log': async (payload) => {
const { cwd } = cwdOf(payload)
return git.log(cwd)
const record = payload as { count?: unknown; skip?: unknown }
const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0
? record.count
: undefined
const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0
? record.skip
: undefined
return git.log(cwd, count, skip)
},
'git.commit-diff': async (payload) => {
const { cwd } = cwdOf(payload)
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) }
},
'git.discard': async (payload) => {
const { cwd } = cwdOf(payload)
await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path')))
return { ok: true }
},
'git.revert': async (payload) => {
const { cwd } = cwdOf(payload)
await git.revert(cwd, requireString(payload, 'hash'))
return { ok: true }
},
'git.cherry-pick': async (payload) => {
const { cwd } = cwdOf(payload)
await git.cherryPick(cwd, requireString(payload, 'hash'))
return { ok: true }
},
'git.show': async (payload) => {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
const rev = requireString(payload, 'rev')
return { content: await git.show(cwd, rev, path) }
},
@@ -240,6 +285,15 @@ function buildApi(
ptyManager.close(`${sessionId}:${tab}`)
return { ok: true }
},
// Release an agent terminal by uuid. The WS close frame already does
// this while the socket is open; this route covers the tab-close that
// happens while the socket is down (reconnect loop) so a closed agent
// tab never leaves a zombie pty behind. Idempotent.
'agent-pty.close': (payload) => {
const uuid = requireString(payload, 'uuid')
agentPtyRegistry.close(uuid)
return { ok: true }
},
// The side card preferences. The settings service is optional in the
// composition; while absent the routes report undefined and the client
// keeps the schema defaults. Writes are revision-guarded: a stale editor
@@ -287,6 +341,12 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const trustedHosts = trustedHostsOf(ctx)
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
// uncapped, and torn down with the plugin. The model creates terminals here
// through the terminal_create tool; the sidebar view attaches through the
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
const agentPtyRegistry = new AgentPtyRegistry(defaultShell())
// ── User-facing "Side card" preferences ──────────────────────────────────
// Register the namespace with the settings provider so the Settings page
@@ -297,9 +357,34 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// which call the seam in-process. Deployments without a settings service
// simply never fill the face and the client falls back to the defaults.
let settingsFace: SidebarSettingsFace | undefined
// The model-facing terminal tools are gated on the side-card setting
// `agentTerminalTools` (default off): nothing is injected until the user
// turns the feature on, and turning it off mid-session unregisters the
// tools and releases the agent terminals they created.
let toolsDisposers: (() => void) | null = null
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
if (scope.get().agentTerminalTools) {
if (toolsDisposers === null) {
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
}
} else if (toolsDisposers !== null) {
toolsDisposers()
toolsDisposers = null
// The feature is off: release every agent terminal the model created
// while it was on (they are only reachable through the tools). The
// registry change fires the push, so the sidebar reconciles them away.
agentPtyRegistry.disposeAll()
}
}
ctx.inject(['settings'], (sctx) => {
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
sctx.settings.register(ns, PrefsSchema)
// The structural settings mirror types `schema` as unknown, so the
// generic is not inferred here; the real service resolves it from the
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
const scope = sctx.settings.register(ns, PrefsSchema) as {
get(): SidebarPrefs
watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
}
const viewOf = (): { value?: unknown; revision?: number } => {
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
return descriptor === undefined
@@ -313,10 +398,14 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
return viewOf()
},
}
// Register (or unregister) the terminal tools from the current setting,
// and keep them in sync with every settings commit.
syncToolsGate(scope)
scope.watch(() => { syncToolsGate(scope) })
})
// ── JSON API ────────────────────────────────────────────────────────────
const api = buildApi(ctx, ptyManager, resolved, () => settingsFace)
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
ctx.effect(() => ctx.httpServer.register({
kind: 'prefix',
path: '/sidebar/api',
@@ -398,6 +487,13 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
}), 'dsh-better-sidebar: /sidebar/file media route')
// ── Terminal WebSocket ──────────────────────────────────────────────────
// One upgrade endpoint serves both UI-tab terminals (?tab=...) and
// agent-owned terminals (?uuid=...). The two paths attach to different
// registries but share the wire protocol: input frames are raw text,
// resize frames are JSON `{type:'resize',cols,rows}`, and a close frame
// `{type:'close'}` releases the underlying pty (immediate for agent
// terminals, scheduled-0 for UI tabs which keep the same reconnect grace
// contract the host has always had).
const wss = new WebSocketServer({ noServer: true })
ctx.effect(() => ctx.httpServer.registerUpgrade({
path: '/sidebar/ws/terminal',
@@ -406,30 +502,106 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
socket.destroy()
return
}
wss.handleUpgrade(req, socket, head, (ws) => { void attachTerminal(ctx, ptyManager, ws, req, resolved) })
wss.handleUpgrade(req, socket, head, (ws) => {
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
})
},
}), 'dsh-better-sidebar: terminal WebSocket')
// ── Agent terminals push WebSocket ──────────────────────────────────────
// Pushes the live list of agent terminals for one session to the sidebar
// view: the client mirrors the list into tabs (id `agent:<uuid>`,
// title from the agent's `terminal_create` call). The host fires on every
// create / close / exit; the client reconciles by adding tabs for new
// uuids and dropping tabs whose uuids disappeared (the user closing a tab
// sends `{type:'close'}` on the terminal WS, which kills the pty, which
// fires a change here, which converges the view).
const agentListWss = new WebSocketServer({ noServer: true })
ctx.effect(() => ctx.httpServer.registerUpgrade({
path: '/sidebar/ws/agent-terminals',
handler: (req, socket, head) => {
if (!fence(req)) {
socket.destroy()
return
}
agentListWss.handleUpgrade(req, socket, head, (ws) => {
void attachAgentList(agentPtyRegistry, ws, req)
})
},
}), 'dsh-better-sidebar: agent-terminals push WebSocket')
ctx.effect(() => () => {
toolsDisposers?.()
ptyManager.disposeAll()
agentPtyRegistry.disposeAll()
wss.close()
agentListWss.close()
}, 'dsh-better-sidebar: teardown')
}
/** Wire one terminal socket to its pty: replay transcript, pump both ways. */
/** Push the live agent-terminal list for one session to a connected sidebar view. */
async function attachAgentList(
registry: AgentPtyRegistry,
ws: WebSocket,
req: IncomingMessage,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const sessionId = url.searchParams.get('sessionId')
if (sessionId === null) {
ws.close(1008, 'sessionId is required')
return
}
const send = (): void => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(registry.list(sessionId)))
}
}
send()
const unsubscribe = registry.subscribe(send)
ws.on('close', () => { unsubscribe() })
ws.on('error', () => { unsubscribe() })
} catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error))
}
}
/**
* Wire one terminal socket to its pty: replay transcript, pump both ways.
* Two attach modes share the wire protocol:
* - `?uuid=...` attaches to an agent-owned terminal (created by the
* `terminal_create` tool). The close frame kills the pty immediately
* (the agent's terminal closes when the user closes the sidebar tab); a
* bare socket drop (refresh, tab switch) leaves the pty alive for the
* reconnect grace, exactly like UI-tab terminals.
* - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
* created it from the + menu). The close frame schedules a 0-ms close
* (the host's reconnect grace keeps the shell alive across a refresh).
*/
async function attachTerminal(
ctx: Context,
ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry,
ws: WebSocket,
req: IncomingMessage,
resolved: ResolvedSidebarConfig,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const uuid = url.searchParams.get('uuid')
if (uuid !== null) {
const handle = agentPtyRegistry.get(uuid)
if (handle === undefined) {
ws.close(1011, `agent terminal "${uuid}" not found`)
return
}
pumpAgentTerminal(agentPtyRegistry, handle, ws)
return
}
const sessionId = url.searchParams.get('sessionId')
const tabId = url.searchParams.get('tab')
if (sessionId === null || tabId === null) {
ws.close(1008, 'sessionId and tab are required')
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
return
}
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
@@ -470,7 +642,8 @@ async function attachTerminal(
&& control.type === 'resize'
&& typeof control.cols === 'number' && typeof control.rows === 'number'
) {
handle.pty.resize(Math.max(2, Math.floor(control.cols)), Math.max(2, Math.floor(control.rows)))
const dims = clampDims(control.cols, control.rows)
handle.pty.resize(dims.cols, dims.rows)
} else {
handle.pty.write(text)
}
@@ -487,3 +660,71 @@ async function attachTerminal(
ws.close(1011, error instanceof Error ? error.message : String(error))
}
}
/**
* Pump one agent terminal's pty to a connected view. The close frame kills
* the pty immediately (the agent's terminal closes when the user closes the
* sidebar tab); a bare socket drop leaves the pty alive — the agent owns
* the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
* plugin teardown kills it.
*/
function pumpAgentTerminal(
registry: AgentPtyRegistry,
handle: AgentTerminalHandle,
ws: WebSocket,
): void {
if (handle.transcript !== '') ws.send(handle.transcript)
const onData = (data: string): void => {
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
ws.send(data)
}
}
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
}
const dataSub = handle.pty.onData(onData)
const exitSub = handle.pty.onExit(onExit)
ws.on('message', (data) => {
if (handle.exited) return
const text = data.toString('utf8')
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
try {
const parsed: unknown = JSON.parse(text)
if (parsed !== null && typeof parsed === 'object') {
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
}
} catch {
// Not JSON: terminal input.
}
if (control !== null && control.type === 'close') {
// The user closed the sidebar tab: kill the pty immediately. The
// agent's next terminal_list / terminal_send will see it gone.
registry.close(handle.uuid)
return
}
if (
control !== null
&& control.type === 'resize'
&& typeof control.cols === 'number' && typeof control.rows === 'number'
) {
const dims = clampDims(control.cols, control.rows)
handle.pty.resize(dims.cols, dims.rows)
} else if (control === null) {
// Raw text input (a JSON-looking string the pty would have received
// verbatim is reachable in theory but is exotic for an agent terminal;
// preserve the UI-tab semantics and forward as input).
handle.pty.write(text)
}
// An unrecognized JSON control frame is dropped (the UI-tab path also
// treats non-resize JSON controls as input, but for an agent terminal
// there is no realistic input that is also valid JSON).
})
ws.on('close', () => {
dataSub.dispose()
exitSub.dispose()
// A bare socket drop (refresh, tab switch) leaves the agent's pty alive.
// The agent owns the lifetime: only `terminal_close`, a `{type:'close'}`
// frame, or plugin teardown kills it. A reconnecting view reattaches the
// same shell and gets the full transcript replayed.
})
}
+8
View File
@@ -20,6 +20,13 @@ export interface SidebarPrefs {
* Subagent page when the current conversation spawns a new subagent.
*/
autoOpenSubagent: boolean
/**
* Whether the model-facing agent terminal tools (terminal_create / list /
* send / read / wait_for / resize / signal / close) are injected into the
* model's toolset. Off by default: the feature stays dormant until the
* user explicitly enables it in the side card settings.
*/
agentTerminalTools: boolean
}
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
@@ -32,6 +39,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
agentTerminalTools: false,
}
/** Clamp one width percent into the contract range (shared by schema and client reads). */
+477
View File
@@ -0,0 +1,477 @@
/**
* Eight model-facing tools for the agent-owned sidebar terminals (tmux
* semantics: spawn-and-detach, send-keys, read, wait-for, resize, signal,
* close, list). Each tool binds to the calling agent's session through
* `exec.agent.session.id`, so the model never passes a sessionId — the
* agent identity is the scope.
*
* Conventions (per plugin-development-guide.md §3):
* C1 — parameters schema-validated before `execute` runs.
* C4 — `execute` returns one canonical JSON value; `render` is a separate
* pure text projection.
* C6 — `exec.signal.throwIfAborted()` before any spawn.
* C10 — no UI/transport vocabulary in the canonical value.
*/
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Context } from './context-types.ts'
import {
AgentPtyRegistry,
ALLOWED_SIGNALS,
type AgentTerminalSignal,
type AgentTerminalSnapshot,
} from './agent-pty.ts'
/** Maximum UTF-8 bytes of one `terminal_read` result text. */
const READ_BYTE_LIMIT = 256 * 1024
/**
* Bound a string to a byte limit, marking truncation. Truncation never
* splits a multi-byte UTF-8 sequence: when the byte cap lands inside one,
* the walk-back retreats to the sequence's leading byte so the retained
* prefix decodes cleanly (a split would decode to U+FFFD).
* @internal exported for the unit tests, like {@link snapshotOf}.
*/
export function boundBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
const buf = Buffer.from(text, 'utf8')
if (buf.byteLength <= maxBytes) return { text, truncated: false }
// The byte at `end` is a continuation byte (10xxxxxx) exactly when the
// character that started before `end` spills past the cap. Stop at the
// first non-continuation byte — the retained prefix is then intact.
let end = maxBytes
while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80) end -= 1
return { text: buf.subarray(0, end).toString('utf8'), truncated: true }
}
/** Pure text projection helper (the canonical value is already structured). */
function textRender<T>(fn: (value: T) => string): (_args: unknown, value: unknown) => ContentBlock[] {
return (_args, value) => [{ type: 'text', text: fn(value as T) }]
}
/** Extract the calling agent or throw the canonical "no agent" error. */
function requireAgent(agent: Agent | undefined): Agent {
if (agent === undefined) {
throw new Error('sidebar terminal tools require an initiating agent')
}
return agent
}
/** Resolve the calling agent's session id (the registry scope + ownership key). */
function sessionIdOf(exec: ToolRunContext): string {
return requireAgent(exec.agent).session.id
}
/**
* Register the eight terminal tools against the host tool registry. The
* `resolveCwd` callback threads the live session cwd (authoritative from the
* session store, falling back to the process cwd) so a freshly-created
* terminal lands in the right directory without the model passing it.
* Every uuid-keyed tool first asserts the terminal belongs to the calling
* session (`registry.assertOwned`), so one agent can never reach another
* session's terminals.
* @param ctx - host plugin context (carries the tools service).
* @param registry - the agent-owned terminal registry.
* @param resolveCwd - live cwd resolver for one session id.
* @returns a disposer that unregisters all eight tools (the caller gates
* registration on the side-card setting and calls this to turn them off).
*/
export function registerTools(
ctx: Context,
registry: AgentPtyRegistry,
resolveCwd: (sessionId: string) => string,
): () => void {
const disposers: Array<() => void> = []
const register = (tool: ReturnType<typeof defineTool>): void => {
disposers.push(ctx.tools.register(tool))
}
register(defineTool({
name: 'terminal_create',
description:
'Open a persistent terminal in the sidebar and run a command in it. '
+ 'Spawns an interactive shell, writes the command + Enter to its stdin, and returns a uuid handle. '
+ 'The terminal stays alive after the command exits — send more input with terminal_send (set submit=true to run a command), '
+ 'read output with terminal_read, send Ctrl+C with terminal_signal(signal="SIGINT"), '
+ 'and close it with terminal_close when done. '
+ 'Use this for interactive shells, REPLs, long-running dev servers, '
+ 'or any work that needs persistent terminal state across tool calls. '
+ 'The terminal appears as a new tab in the right sidebar (titled with the `title` you provide) so the user can watch and interact with it.',
parameters: {
title: {
type: 'string',
required: true,
description: 'Short human-readable label for the terminal tab (e.g. "dev server", "python repl").',
},
command: {
type: 'string',
required: true,
description: 'Shell command to run in the freshly spawned shell. The host appends an Enter key automatically — do NOT include a trailing newline. Pass "" to open a bare shell with no command.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true, description: 'Opaque handle for the new terminal. Pass to terminal_send / terminal_read / terminal_resize / terminal_signal / terminal_close.' },
title: { type: 'string', required: true, description: 'The title you provided (echoed for confirmation).' },
},
},
render: textRender((v: { uuid: string; title: string }) =>
`Opened terminal "${v.title}" (uuid: ${v.uuid}). The sidebar tab appears automatically; use terminal_read to see output and terminal_send (with submit=true) to run more commands.`,
),
},
execute: (args: { title: string; command: string }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
const cwd = resolveCwd(sessionId)
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24)
return Promise.resolve({ uuid, title: args.title })
},
}))
register(defineTool({
name: 'terminal_list',
description:
'List every terminal the current agent has opened in this session. Returns each terminal\'s uuid, title, '
+ 'the command it was started with, and whether the top-level process has exited (with exit code/signal if so). '
+ 'Use this to recover state after a long sequence of tool calls or to find a terminal you forgot to close.',
parameters: {},
output: {
schema: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
title: { type: 'string', required: true },
command: { type: 'string', required: true },
exited: { type: 'boolean', required: true },
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
},
},
},
render: (_args, value) => {
const list = value as AgentTerminalSnapshot[]
if (list.length === 0) return [{ type: 'text', text: 'No agent terminals open in this session.' }]
const lines = list.map((t) => {
const status = t.exited
? `exited (code ${t.exitCode ?? '?'}, signal ${t.exitSignal ?? 'none'})`
: 'running'
return ` ${t.uuid} "${t.title}" [${status}] $ ${t.command}`
})
return [{ type: 'text', text: `Agent terminals in this session:\n${lines.join('\n')}` }]
},
},
execute: (_args, exec) => {
const sessionId = sessionIdOf(exec)
return Promise.resolve(registry.list(sessionId))
},
}))
register(defineTool({
name: 'terminal_send',
description:
'Send raw text (keystrokes) to a terminal opened with terminal_create — tmux send-keys semantics. '
+ 'The text is written verbatim to the pty stdin. '
+ 'To submit a command, set submit=true (appends an Enter key); do NOT put "\\n" or "\\r" in the text yourself. '
+ 'To send Ctrl+C (interrupt the running command), use the terminal_signal tool with signal="SIGINT" — do NOT try to send the control character "\\u0003" as text. '
+ 'Use terminal_signal with signal="SIGTSTP" for Ctrl+Z (suspend) as well. '
+ 'This tool does NOT wait for the command to finish or for output to settle — pair with terminal_read to observe the result. '
+ 'Throws if the terminal has exited.',
parameters: {
uuid: {
type: 'string',
required: true,
description: 'Terminal uuid from terminal_create or terminal_list.',
},
text: {
type: 'string',
required: true,
description: 'UTF-8 text to write to the terminal stdin (verbatim, no shell escaping). Do not include trailing newlines — use the submit flag instead.',
},
submit: {
type: 'boolean',
description: 'Append an Enter key (carriage return) after the text to submit a command. Default: false. Set to true when sending a command to run; leave false for partial input or control sequences.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
bytes: { type: 'integer', required: true, description: 'Number of UTF-8 bytes written (including the Enter key if submit was true).' },
},
},
render: textRender((v: { uuid: string; bytes: number }) =>
`Sent ${v.bytes} byte(s) to terminal ${v.uuid}.`,
),
},
execute: (args: { uuid: string; text: string; submit?: boolean }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const payload = args.submit === true ? `${args.text}\r` : args.text
registry.send(args.uuid, payload)
return Promise.resolve({ uuid: args.uuid, bytes: Buffer.byteLength(payload, 'utf8') })
},
}))
register(defineTool({
name: 'terminal_read',
description:
'Read a bounded page of retained output from an agent terminal without sending input. '
+ 'The host keeps up to ~1 MiB of scrollback; this tool returns up to 500 lines per call. '
+ 'Use `offset` to paginate forward ( 0-based from the start of the retained transcript ) or backward ( negative reads from the end, e.g. -50 reads the last 50 lines ). '
+ 'Returns `totalLines` so you know how much scrollback remains. '
+ 'Output is bounded to 256 KiB per call; longer pages are truncated with the `truncated` flag.',
parameters: {
uuid: {
type: 'string',
required: true,
description: 'Terminal uuid from terminal_create or terminal_list.',
},
offset: {
type: 'number',
description: '0-based line offset from the start of the retained transcript (default 0). Negative reads from the end (e.g. -50 = last 50 lines).',
},
count: {
type: 'number',
description: 'Maximum lines to return (default 500, hard cap 500).',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
text: { type: 'string', required: true, description: 'The slice of transcript for the requested page.' },
totalLines: { type: 'integer', required: true, description: 'Total lines in the retained transcript.' },
lineBegin: { type: 'integer', required: true, description: '0-based index of the first line in `text` (inclusive).' },
lineEnd: { type: 'integer', required: true, description: '0-based index of the last line in `text` (exclusive).' },
truncated: { type: 'boolean', required: true, description: 'Whether `text` was truncated to fit the 256 KiB read cap.' },
},
},
render: (_args, value) => {
const v = value as { text: string; totalLines: number; lineBegin: number; lineEnd: number; truncated: boolean }
const head = `[lines ${v.lineBegin}..${v.lineEnd} of ${v.totalLines}${v.truncated ? '; truncated to 256KiB' : ''}]`
return [{ type: 'text', text: `${head}\n${v.text}` }]
},
},
execute: (args: { uuid: string; offset?: number; count?: number }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const result = registry.read(args.uuid, args.offset, args.count)
const bounded = boundBytes(result.text, READ_BYTE_LIMIT)
return Promise.resolve({
text: bounded.text,
totalLines: result.totalLines,
lineBegin: result.lineBegin,
lineEnd: result.lineEnd,
truncated: bounded.truncated,
})
},
}))
register(defineTool({
name: 'terminal_wait_for',
description:
'Block until a substring appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. '
+ 'Use this to synchronize on command completion cues ( e.g. a shell prompt, "done", "Listening on", "Build successful" ) '
+ 'without busy-polling terminal_read. '
+ 'The wait scans the FULL retained transcript (up to ~1 MiB) on every poll, so a needle that scrolled past the most recent chunk is still a match. '
+ 'Returns `found` with the line/column of the first occurrence, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. '
+ 'Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). '
+ 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately.',
parameters: {
uuid: {
type: 'string',
required: true,
description: 'Terminal uuid from terminal_create or terminal_list.',
},
needle: {
type: 'string',
required: true,
description: 'Substring to wait for (case-sensitive, verbatim). Must be non-empty.',
},
timeout_ms: {
type: 'number',
description: 'Maximum wait in milliseconds (default 10000, i.e. 10s). Clamped to a minimum of 100ms.',
},
},
output: {
schema: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'found' },
needle: { type: 'string', required: true },
line: { type: 'integer', required: true, description: '0-based line index in the retained transcript where the needle first appeared.' },
column: { type: 'integer', required: true, description: '0-based column index within that line where the match starts.' },
elapsedMs: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'timeout' },
needle: { type: 'string', required: true },
timeoutMs: { type: 'integer', required: true, description: 'The configured timeout that elapsed.' },
totalLines: { type: 'integer', required: true, description: 'Total lines retained when the timeout fired. Call terminal_read to inspect the tail.' },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'exited' },
needle: { type: 'string', required: true },
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }], description: 'Exit code, if known.' },
exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'Exit signal name, if killed by a signal.' },
},
},
],
},
render: (_args, value) => {
const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; exitCode?: number | null; exitSignal?: string | null }
if (v.kind === 'found') {
return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).` }]
}
if (v.kind === 'timeout') {
return [{ type: 'text', text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }]
}
const exitInfo = v.exitCode !== undefined && v.exitCode !== null ? ` (exit code ${v.exitCode})` : ''
return [{ type: 'text', text: `Terminal exited before "${v.needle}" appeared${exitInfo}.` }]
},
},
async execute(args: { uuid: string; needle: string; timeout_ms?: number }, exec) {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
// The registry validates the needle (empty → bad-request) and returns
// the camelCase result the schema above declares directly — no
// field-by-field projection.
const timeoutMs = args.timeout_ms ?? 10_000
return await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal)
},
}))
register(defineTool({
name: 'terminal_resize',
description:
'Resize an agent terminal\'s pty ( cols × rows ). The host clamps both to a 2..1024 sane range. '
+ 'Most shells redraw their prompt and any full-screen TUI on the next output frame. '
+ 'No-op if the terminal has exited. Returns the dimensions actually applied.',
parameters: {
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
cols: { type: 'integer', required: true, description: 'New column count ( clamped to 2..1024 ).' },
rows: { type: 'integer', required: true, description: 'New row count ( clamped to 2..1024 ).' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
cols: { type: 'integer', required: true },
rows: { type: 'integer', required: true },
},
},
render: textRender((v: { uuid: string; cols: number; rows: number }) =>
`Resized terminal ${v.uuid} to ${v.cols}×${v.rows}.`,
),
},
execute: (args: { uuid: string; cols: number; rows: number }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const dims = registry.resize(args.uuid, args.cols, args.rows)
return Promise.resolve({ uuid: args.uuid, ...dims })
},
}))
register(defineTool({
name: 'terminal_signal',
description:
'Send a POSIX signal to an agent terminal\'s foreground process — this is how you send Ctrl+C, Ctrl+Z, etc. '
+ 'Use signal="SIGINT" for Ctrl+C (interrupt the running command), signal="SIGTERM" to request termination, '
+ 'signal="SIGKILL" to force-kill the pty, signal="SIGHUP" to hang up (many shells exit), signal="SIGTSTP" for Ctrl+Z (suspend). '
+ 'Do NOT try to send control characters (like "\\u0003") through terminal_send — use this tool instead. '
+ 'On Windows, only SIGKILL and SIGTERM are effective — others are accepted but may no-op. '
+ 'No-op if the terminal has already exited. Use terminal_close to dispose of the terminal entirely.',
parameters: {
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
signal: {
type: 'string',
required: true,
enum: ALLOWED_SIGNALS as readonly string[],
description: 'Signal to deliver: SIGINT (Ctrl+C) | SIGTERM | SIGKILL | SIGHUP | SIGTSTP (Ctrl+Z).',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
signal: { type: 'string', required: true },
},
},
render: textRender((v: { uuid: string; signal: AgentTerminalSignal }) =>
`Sent ${v.signal} to terminal ${v.uuid}.`,
),
},
execute: (args: { uuid: string; signal: AgentTerminalSignal }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
registry.signal(args.uuid, args.signal)
return Promise.resolve({ uuid: args.uuid, signal: args.signal })
},
}))
register(defineTool({
name: 'terminal_close',
description:
'Close an agent terminal and release its process. The uuid becomes invalid for all subsequent tool calls. '
+ 'Idempotent: closing an already-closed uuid is a no-op. '
+ 'The corresponding sidebar tab is removed automatically when the host pushes the updated terminal list. '
+ 'Always close terminals you no longer need — the host keeps the pty alive until you do.',
parameters: {
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
closed: { type: 'boolean', required: true, description: 'Whether a live terminal was actually dropped (false if the uuid was already gone).' },
},
},
render: textRender((v: { uuid: string; closed: boolean }) =>
v.closed ? `Closed terminal ${v.uuid}.` : `Terminal ${v.uuid} was already closed.`,
),
},
execute: (args: { uuid: string }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const closed = registry.close(args.uuid)
return Promise.resolve({ uuid: args.uuid, closed })
},
}))
return () => {
for (const dispose of disposers) dispose()
}
}
+340
View File
@@ -0,0 +1,340 @@
/**
* AgentPtyRegistry unit tests: spawn real ptys (mirroring the pty-manager
* smoke tests) and verify uuid-keyed CRUD, transcript bounding, exit
* tracking, and change-event subscription. The "AttachConsole failed"
* console noise from node-pty's ConPTY module on Windows is expected and
* does not affect the assertions.
*/
import { describe, expect, it } from 'vitest'
import { AgentPtyRegistry, ALLOWED_SIGNALS, snapshotOf, type AgentTerminalSnapshot } from '../src/agent-pty.ts'
/**
* Resolve a shell binary for tests: on Windows use PowerShell (available on
* every modern Windows host), on POSIX use /bin/sh (universally available).
*/
function testShell(): string {
return process.platform === 'win32' ? 'powershell.exe' : '/bin/sh'
}
/** Wait for a terminal's transcript to contain a substring (or timeout). */
async function waitForTranscript(
registry: AgentPtyRegistry,
uuid: string,
needle: string,
timeoutMs = 5000,
): Promise<string> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const handle = registry.get(uuid)
if (handle !== undefined && handle.transcript.includes(needle)) return handle.transcript
await new Promise(resolve => setTimeout(resolve, 50))
}
const handle = registry.get(uuid)
return handle?.transcript ?? ''
}
describe('AgentPtyRegistry', () => {
it('creates a terminal with a uuid, writes the command to stdin, and lists it', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'echo test', 'echo hello-agent-pty', process.cwd(), 80, 24)
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
const list = registry.list('s1')
expect(list).toHaveLength(1)
expect(list[0]!.uuid).toBe(uuid)
expect(list[0]!.title).toBe('echo test')
expect(list[0]!.command).toBe('echo hello-agent-pty')
expect(list[0]!.exited).toBe(false)
// The command was written to stdin; wait for the output.
const transcript = await waitForTranscript(registry, uuid, 'hello-agent-pty')
expect(transcript).toContain('hello-agent-pty')
} finally {
registry.disposeAll()
}
})
it('spawns a bare shell when command is empty', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'bare', '', process.cwd(), 80, 24)
const handle = registry.get(uuid)
expect(handle).toBeDefined()
expect(handle!.command).toBe('')
expect(handle!.exited).toBe(false)
// The pty process exists; its transcript may take a moment to
// accumulate on Windows ConPTY. The handle is alive and listed.
expect(registry.list('s1')).toHaveLength(1)
} finally {
registry.disposeAll()
}
})
it('sends raw text to stdin (send-keys semantics)', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'sender', '', process.cwd(), 80, 24)
// Wait for the shell prompt, then send a command.
await waitForTranscript(registry, uuid, '', 1000)
// The registry's send() writes verbatim; the caller (tool layer) is
// responsible for appending \r when submit=true. Here we test the
// registry directly: send text + \r to submit.
registry.send(uuid, 'echo sent-via-send\r')
const transcript = await waitForTranscript(registry, uuid, 'sent-via-send')
expect(transcript).toContain('sent-via-send')
} finally {
registry.disposeAll()
}
})
it('reads a bounded page of the transcript', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'reader', 'echo line1\necho line2\necho line3', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'line3')
const page = registry.read(uuid)
expect(page.totalLines).toBeGreaterThan(0)
expect(page.text).toContain('line1')
expect(page.text).toContain('line3')
// Negative offset reads from the end.
const tail = registry.read(uuid, -2)
expect(tail.lineBegin).toBeGreaterThanOrEqual(0)
expect(tail.lineEnd).toBe(tail.lineBegin + tail.text.split('\n').length)
} finally {
registry.disposeAll()
}
})
it('resizes without throwing', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'resizer', '', process.cwd(), 80, 24)
expect(() => registry.resize(uuid, 120, 40)).not.toThrow()
} finally {
registry.disposeAll()
}
})
it('resize clamps to the 2..1024 range and returns the applied dimensions', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'clamp', '', process.cwd(), 80, 24)
// Oversized / undersized requests report the clamped values, so the
// echoed result always matches the pty (the tool contract).
expect(registry.resize(uuid, 5000, 1)).toEqual({ cols: 1024, rows: 2 })
expect(registry.resize(uuid, -3, 80.9)).toEqual({ cols: 2, rows: 80 })
expect(registry.resize(uuid, 120, 40)).toEqual({ cols: 120, rows: 40 })
// create clamps through the same helper.
const other = registry.create('s1', 'clamped-create', '', process.cwd(), 1, 9999)
expect(registry.get(other)!.pty.cols).toBe(2)
expect(registry.get(other)!.pty.rows).toBe(1024)
} finally {
registry.disposeAll()
}
})
it('assertOwned rejects a uuid owned by another session', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const mine = registry.create('s1', 'mine', '', process.cwd(), 80, 24)
const theirs = registry.create('s2', 'theirs', '', process.cwd(), 80, 24)
expect(() => registry.assertOwned(mine, 's1')).not.toThrow()
// A foreign uuid is indistinguishable from an unknown one.
expect(() => registry.assertOwned(theirs, 's1')).toThrow(/not found/)
expect(() => registry.assertOwned('nope', 's1')).toThrow(/not found/)
} finally {
registry.disposeAll()
}
})
it('closes a terminal and removes it from the list', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'closer', '', process.cwd(), 80, 24)
expect(registry.list('s1')).toHaveLength(1)
const closed = registry.close(uuid)
expect(closed).toBe(true)
expect(registry.list('s1')).toHaveLength(0)
// Closing again is a no-op.
const closedAgain = registry.close(uuid)
expect(closedAgain).toBe(false)
} finally {
registry.disposeAll()
}
})
it('scopes list by session id', () => {
const registry = new AgentPtyRegistry(testShell())
try {
registry.create('s1', 'a', '', process.cwd(), 80, 24)
registry.create('s1', 'b', '', process.cwd(), 80, 24)
registry.create('s2', 'c', '', process.cwd(), 80, 24)
expect(registry.list('s1')).toHaveLength(2)
expect(registry.list('s2')).toHaveLength(1)
expect(registry.list('s3')).toHaveLength(0)
} finally {
registry.disposeAll()
}
})
it('fires change listeners on create, close, and exit', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
let changeCount = 0
registry.subscribe(() => { changeCount += 1 })
const uuid = registry.create('s1', 'watched', '', process.cwd(), 80, 24)
// create fires one change.
expect(changeCount).toBeGreaterThanOrEqual(1)
registry.close(uuid)
// close fires another change.
expect(changeCount).toBeGreaterThanOrEqual(2)
} finally {
registry.disposeAll()
}
})
it('disposeAll closes every terminal', () => {
const registry = new AgentPtyRegistry(testShell())
registry.create('s1', 'a', '', process.cwd(), 80, 24)
registry.create('s2', 'b', '', process.cwd(), 80, 24)
registry.disposeAll()
expect(registry.list('s1')).toHaveLength(0)
expect(registry.list('s2')).toHaveLength(0)
})
it('snapshotOf drops the pty reference and transcript', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'snap', '', process.cwd(), 80, 24)
const handle = registry.get(uuid)!
const snap = snapshotOf(handle)
expect(snap.uuid).toBe(uuid)
expect(snap.title).toBe('snap')
expect(snap.exited).toBe(false)
// No pty or transcript fields on the snapshot.
expect('pty' in snap).toBe(false)
expect('transcript' in snap).toBe(false)
// sessionId is registry-internal ownership, never serialized (it would
// violate the terminal_list output schema's additionalProperties:false).
expect('sessionId' in snap).toBe(false)
expect('sessionId' in registry.list('s1')[0]!).toBe(false)
} finally {
registry.disposeAll()
}
})
it('ALLOWED_SIGNALS includes SIGINT, SIGTERM, SIGKILL, SIGHUP, SIGTSTP', () => {
expect(ALLOWED_SIGNALS).toContain('SIGINT')
expect(ALLOWED_SIGNALS).toContain('SIGTERM')
expect(ALLOWED_SIGNALS).toContain('SIGKILL')
expect(ALLOWED_SIGNALS).toContain('SIGHUP')
expect(ALLOWED_SIGNALS).toContain('SIGTSTP')
})
it('waitFor returns found when the needle is already in the transcript', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'echo-test', 'echo wait-for-fast', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'wait-for-fast')
// The needle is already present; waitFor should return immediately.
const result = await registry.waitFor(uuid, 'wait-for-fast', 2000)
expect(result.kind).toBe('found')
if (result.kind === 'found') {
expect(result.needle).toBe('wait-for-fast')
expect(result.elapsedMs).toBeLessThan(500)
}
} finally {
registry.disposeAll()
}
})
it('waitFor returns found after the needle appears (async output)', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
// Spawn a shell WITH a command (so output is guaranteed), but start
// the wait for a DIFFERENT needle that only appears after we send
// a follow-up command. This exercises the subscribe/poll path: the
// wait must notice output arriving AFTER it started, not just find
// pre-existing content.
const uuid = registry.create('s1', 'async-echo', 'echo shell-ready', process.cwd(), 80, 24)
// Wait for the initial command's output so we know the shell is live.
await waitForTranscript(registry, uuid, 'shell-ready', 10_000)
// Start the wait BEFORE sending the follow-up command. The needle is
// NOT in the transcript yet. ConPTY under test concurrency can be slow,
// so the timeout is generous.
const waitPromise = registry.waitFor(uuid, 'UNIQUE_NEEDLE_42', 30_000)
// Give the wait a moment to subscribe, then send the command.
await new Promise(resolve => setTimeout(resolve, 200))
registry.send(uuid, 'echo UNIQUE_NEEDLE_42\r')
const result = await waitPromise
expect(result.kind).toBe('found')
if (result.kind === 'found') {
expect(result.needle).toBe('UNIQUE_NEEDLE_42')
}
} finally {
registry.disposeAll()
}
}, 40_000)
it('waitFor returns timeout when the needle never appears', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'silent', '', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, '', 1000)
// Wait for a needle that will never appear; use a short timeout to
// keep the test fast.
const result = await registry.waitFor(uuid, 'this-needle-will-never-appear', 500)
expect(result.kind).toBe('timeout')
if (result.kind === 'timeout') {
expect(result.timeoutMs).toBe(500)
expect(result.totalLines).toBeGreaterThan(0)
}
} finally {
registry.disposeAll()
}
})
it('waitFor throws on an empty needle', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'empty-needle', '', process.cwd(), 80, 24)
await expect(registry.waitFor(uuid, '', 500)).rejects.toThrow()
} finally {
registry.disposeAll()
}
})
it('waitFor throws on an unknown uuid', async () => {
const registry = new AgentPtyRegistry(testShell())
await expect(registry.waitFor('nonexistent-uuid', 'foo', 500)).rejects.toThrow()
})
it('delivers SIGINT and SIGTSTP by writing control characters (cross-platform)', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'signal-test', '', process.cwd(), 80, 24)
// SIGINT and SIGTSTP are delivered by writing \x03 / \x1a to the pty
// stdin — the cross-platform way terminals send Ctrl+C / Ctrl+Z.
// These must NOT throw (the old kill(SIGINT) path threw on Windows).
expect(() => registry.signal(uuid, 'SIGINT')).not.toThrow()
expect(() => registry.signal(uuid, 'SIGTSTP')).not.toThrow()
// The control byte was written to the pty; the shell received it.
// On POSIX the line discipline generates the signal; on Windows
// ConPTY translates it. Either way the write path is exercised.
} finally {
registry.disposeAll()
}
})
it('delivers SIGKILL via the process-termination path', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'kill-test', '', process.cwd(), 80, 24)
// SIGKILL uses pty.kill() (TerminateProcess on Windows). Must not
// throw even when the pty's kill() rejects named signals — the
// fallback to the default kill ensures the signal takes effect.
expect(() => registry.signal(uuid, 'SIGKILL')).not.toThrow()
} finally {
registry.disposeAll()
}
})
})
+5 -2
View File
@@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => {
const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown>
expect(unwrapped).toBe(sidebar)
expect(unwrapped.name).toBe('dsh-better-sidebar')
expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader'])
expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader', 'tools'])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
@@ -45,12 +45,15 @@ describe('dsh-better-sidebar plugin export shape', () => {
expect(resolved.openByDefault).toBe(true)
expect(resolved.defaultWidthPercent).toBe(30)
expect(resolved.autoOpenSubagent).toBe(true)
// The terminal tools default OFF (the feature is dormant until the user
// enables it in the side card settings).
expect(resolved.agentTerminalTools).toBe(false)
// A stored overridden value resolves through (the range contract is
// enforced by the settings service on write); the new pref keeps its
// default when the stored document predates it.
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
})
})
+227 -5
View File
@@ -4,8 +4,10 @@
* actual repository, and a real directory listing. Runs with `pnpm test`.
*/
import { describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { resolve as resolvePath } from 'node:path'
import { join, resolve as resolvePath } from 'node:path'
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply, mediaTypeForPath } from '../src/index.ts'
import * as git from '../src/git.ts'
@@ -20,6 +22,7 @@ interface FakeContext {
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
}
sessions: { get: (id: string) => { header: { cwd?: string } } | undefined }
tools: { register: (tool: unknown) => () => void }
effect: (fn: () => void | (() => void), label?: string) => void
/** The settings service never appears in the smoke context: the inject
* callback must never run (mirror of cordis' service-less inject). */
@@ -43,6 +46,7 @@ describe('host plugin smoke', () => {
registerUpgrade: (route) => { upgrades.push(route); return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => () => {} },
// The DSH-vendored cordis runs the registration effect immediately and
// keeps its cleanup for disposal.
effect: (fn) => {
@@ -55,7 +59,7 @@ describe('host plugin smoke', () => {
}
apply(ctx as never)
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file'])
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal'])
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
// Teardown runs without throwing (pty manager has nothing open).
for (const cleanup of effects) cleanup()
})
@@ -73,6 +77,31 @@ describe('host plugin smoke', () => {
expect(branches.names).toContain(branches.current)
})
it('enriches the log (full hash + refs) and renders commit diffs', async () => {
const cwd = process.cwd()
const log = await git.log(cwd)
const first = log[0]!
expect(first.hashFull).toMatch(/^[0-9a-f]{40}$/)
expect(typeof first.refs).toBe('string')
const patch = await git.commitDiff(cwd, first.hashFull)
expect(patch).toContain('diff --git')
})
it('pages the log lazily with skip/count', async () => {
const cwd = process.cwd()
const first = await git.log(cwd, 5, 0)
expect(first).toHaveLength(5)
const second = await git.log(cwd, 5, 5)
expect(second).toHaveLength(5)
// The pages are disjoint windows over the same ordered history.
expect(first[0]!.hashFull).not.toBe(second[0]!.hashFull)
const all = await git.log(cwd, 10, 0)
expect(all.slice(0, 5)).toEqual(first)
expect(all.slice(5)).toEqual(second)
// A skip past the end returns an empty page (the lazy loader's stop sign).
expect(await git.log(cwd, 5, 10_000)).toEqual([])
})
it('pty manager releases the quota on close and respawns after exit', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {
@@ -163,6 +192,110 @@ describe('host plugin smoke', () => {
})
})
/**
* Destructive git operations (discard / revert / cherry-pick) run against a
* throwaway repository under the OS temp dir — never the plugin repo. The
* fixture's commit identity comes from the GIT_AUTHOR / GIT_COMMITTER
* environment variables, confined to the fixture process: no git config is
* touched anywhere (the plugin never sets an identity, and neither does its
* test fixture).
*/
describe('git destructive operations (scratch repository)', () => {
const FIXTURE_IDENTITY = {
GIT_AUTHOR_NAME: 'dsh-better-sidebar-test',
GIT_AUTHOR_EMAIL: 'test@dsh.invalid',
GIT_COMMITTER_NAME: 'dsh-better-sidebar-test',
GIT_COMMITTER_EMAIL: 'test@dsh.invalid',
}
const gitRun = (cwd: string, args: string[]): string => {
const result = spawnSync('git', ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args], {
encoding: 'utf8',
env: { ...process.env, ...FIXTURE_IDENTITY },
})
if (result.status !== 0) {
throw new Error(result.stderr || `git ${args[0] ?? ''} exited with ${String(result.status)}`)
}
return result.stdout
}
/** A fresh repo on branch `main` with one committed file `a.txt`. */
const makeScratchRepo = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-'))
gitRun(dir, ['init', '-q'])
gitRun(dir, ['checkout', '-q', '-b', 'main'])
writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'base'])
return dir
}
it('discard restores the worktree file from the index (staged changes kept)', async () => {
const dir = makeScratchRepo()
try {
// Unstaged-only changes: fully reverts to the committed content.
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
await git.discard(dir, 'a.txt')
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
// Staged changes: the worktree snaps back to the STAGED content and
// the index is untouched (`git checkout -- <path>` restores from the
// index — VSCode's "Discard Changes" semantics).
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
gitRun(dir, ['add', '-A'])
await git.discard(dir, 'a.txt')
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\nCHANGED\nthree\n')
const staged = await git.diff(dir, 'a.txt', true)
expect(staged).toContain('-two')
expect(staged).toContain('+CHANGED')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('revert creates a revert commit', async () => {
const dir = makeScratchRepo()
try {
writeFileSync(join(dir, 'a.txt'), 'one\nTWO\nthree\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'change'])
const featureHash = (await git.log(dir))[0]!.hashFull
await git.revert(dir, featureHash)
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
expect((await git.log(dir))[0]!.subject).toBe('Revert "change"')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('cherry-pick applies a commit from another branch', async () => {
const dir = makeScratchRepo()
try {
gitRun(dir, ['checkout', '-q', '-b', 'feature'])
writeFileSync(join(dir, 'b.txt'), 'feature work\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'feature work'])
const featureHash = (await git.log(dir))[0]!.hashFull
gitRun(dir, ['checkout', '-q', 'main'])
await git.cherryPick(dir, featureHash)
expect(readFileSync(join(dir, 'b.txt'), 'utf8')).toBe('feature work\n')
expect((await git.log(dir))[0]!.subject).toBe('feature work')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('reports a failing destructive operation as a GitCommandError', async () => {
const dir = makeScratchRepo()
try {
// An unknown revision fails before touching anything.
await expect(git.revert(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
await expect(git.cherryPick(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
describe('session cwd resolution over the API route', () => {
interface CtxOverrides {
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
@@ -177,6 +310,7 @@ describe('session cwd resolution over the API route', () => {
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: overrides.sessions ?? { get: () => undefined },
tools: { register: () => () => {} },
// The vendored cordis runs registration effects immediately.
effect: (fn: () => void | (() => void)) => { fn() },
// No settings service: the namespace registration never runs.
@@ -248,12 +382,40 @@ describe('session cwd resolution over the API route', () => {
const missing = await invoke(route, 'pty.close', { sessionId: 's-pty' })
expect(missing.ok).toBe(false)
})
it('git.diff resolves repo-relative paths (session in a subdirectory)', async () => {
// The plugin repo's status paths are relative to the repo top level
// (e.g. `src/git.ts`); a session whose cwd sits inside the repo must
// still load per-file diffs instead of failing with "not an absolute
// path". The session header points INTO the repository.
const route = mount({
sessions: {
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
},
})
const result = await invoke(route, 'git.diff', { sessionId: 's-sub', path: 'src/git.ts', staged: false })
expect(result.ok).toBe(true)
const value = result as unknown as { ok: boolean; value?: { diff: string } }
expect(typeof value.value?.diff).toBe('string')
})
it('fs.read resolves repo-relative paths (untracked diff fallback)', async () => {
const route = mount({
sessions: {
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
},
})
const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'src/git.ts' })
expect(result.ok).toBe(true)
const value = result as unknown as { ok: boolean; value?: { kind: string; content: string } }
expect(value.value?.kind).toBe('text')
expect(value.value?.content).toContain('runGit')
})
})
describe('side card settings routes', () => {
/** A minimal settings seam: register/describe/update with the revision guard. */
const createFakeSettings = () => {
const namespaces = new Map<string, {
const createFakeSettings = () => { const namespaces = new Map<string, {
schema: unknown
value: Record<string, unknown> | undefined
revision: number
@@ -296,6 +458,7 @@ describe('side card settings routes', () => {
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => () => {} },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
if (deps.includes('settings') && settings !== undefined) callback({ settings })
@@ -339,7 +502,7 @@ describe('side card settings routes', () => {
const read = await invoke(route, 'settings.get', {})
expect(read.ok).toBe(true)
expect(read.value).toEqual({
value: { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true },
value: { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false },
revision: 0,
})
@@ -371,3 +534,62 @@ describe('side card settings routes', () => {
expect(result.error?.message).toMatch(/plain object/)
})
})
describe('agent terminal tool gating', () => {
it('injects the eight tools only when the side-card setting is enabled (default off)', () => {
let registered = 0
let disposed = 0
// The tools currently registered (registered minus disposed).
const live = (): number => registered - disposed
// A ref container: the watch callback is only assigned inside a closure,
// which TypeScript's control-flow analysis ignores (the bare variable
// would narrow to null and refuse the optional call).
const watcherRef: { current: (() => void) | null } = { current: null }
let enabled = false
const settings = {
register() {
return {
get: () => ({ agentTerminalTools: enabled }),
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
update: async () => {},
replace: async () => {},
}
},
describe: () => [],
async update() {},
}
const ctx = {
loader: { entries: () => [] },
httpServer: {
register: (route: SidebarWebRoute) => { void route; return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => { registered += 1; return () => { disposed += 1 } } },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: readonly string[], callback: (sctx: { settings: unknown }) => void) => {
if (deps.includes('settings')) callback({ settings })
return () => {}
},
}
apply(ctx as never)
// Default off: no tools are registered even though the settings service is mounted.
expect(live()).toBe(0)
// Flipping the setting on registers all eight tools.
enabled = true
watcherRef.current?.()
expect(live()).toBe(8)
expect(disposed).toBe(0)
// Flipping it back off unregisters them (and releases any agent terminals).
enabled = false
watcherRef.current?.()
expect(live()).toBe(0)
expect(disposed).toBe(8)
// And a redundant toggle registers them fresh (no double-registration per
// flip: the guard only skips when the tools are already live).
enabled = true
watcherRef.current?.()
expect(live()).toBe(8)
expect(registered).toBe(16)
})
})
+223
View File
@@ -0,0 +1,223 @@
/**
* Tool-layer tests for the eight agent-terminal tools: registration shape,
* the combined disposer, canonical-output contract (validated against each
* tool's declared output schema exactly like the registry's
* `createSuccessResult` does), per-session ownership, and the UTF-8
* truncation boundary. The pty registry is stubbed (no real spawn): the
* wiring of the tools themselves is the subject here — pty behavior is
* covered by agent-pty.spec.ts.
*/
import { describe, expect, it } from 'vitest'
import { validateJsonSchemaValue, type ToolDefinition, type ToolRunContext } from '@deepseek-ai/dsh-tools'
import { boundBytes, registerTools } from '../src/tools.ts'
import type { AgentPtyRegistry } from '../src/agent-pty.ts'
import type { Context } from '../src/context-types.ts'
/** One registration captured by the fake tools service. */
interface CapturedTool {
definition: ToolDefinition
}
/** Stub registry: uuid-keyed rows without a real pty. */
class FakeRegistry {
readonly terminals = new Map<string, { sessionId: string; title: string; command: string; exited: boolean }>()
create(sessionId: string, title: string, command: string): string {
const uuid = `uuid-${this.terminals.size + 1}`
this.terminals.set(uuid, { sessionId, title, command, exited: false })
return uuid
}
list(sessionId: string): Array<{ uuid: string; title: string; command: string; exited: boolean }> {
return [...this.terminals.entries()]
.filter(([, row]) => row.sessionId === sessionId)
.map(([uuid, row]) => ({ uuid, title: row.title, command: row.command, exited: row.exited }))
}
assertOwned(uuid: string, sessionId: string): void {
const row = this.terminals.get(uuid)
if (row === undefined || row.sessionId !== sessionId) {
throw new Error(`agent terminal "${uuid}" not found`)
}
}
send(): void {}
read(): { text: string; totalLines: number; lineBegin: number; lineEnd: number } {
// A page larger than the tool's 256 KiB read cap, to exercise boundBytes.
return { text: 'x'.repeat(300 * 1024), totalLines: 1, lineBegin: 0, lineEnd: 1 }
}
resize(_uuid: string, cols: number, rows: number): { cols: number; rows: number } {
return { cols, rows }
}
signal(): void {}
close(): boolean {
return true
}
waitFor(_uuid: string, needle: string): Promise<{ kind: 'found'; needle: string; line: number; column: number; elapsedMs: number }> {
// Mirrors the registry's empty-needle rejection (the tool layer no
// longer duplicates this check).
if (needle === '') return Promise.reject(new Error('needle must be a non-empty string'))
return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, elapsedMs: 1 })
}
}
/** A minimal ToolRunContext for one calling session (only the slices the tools touch). */
function exec(sessionId: string): ToolRunContext {
return {
signal: { throwIfAborted: () => {}, aborted: false },
agent: { session: { id: sessionId } },
} as unknown as ToolRunContext
}
/** Register the eight tools against a fake service; the tools bind to `registry`. */
function mount(): { captured: CapturedTool[]; registry: FakeRegistry; dispose: () => number } {
const captured: CapturedTool[] = []
let disposeCount = 0
const ctx = {
tools: {
register: (tool: unknown): (() => void) => {
captured.push({ definition: tool as ToolDefinition })
return () => { disposeCount += 1 }
},
},
} as unknown as Context
const registry = new FakeRegistry()
const dispose = registerTools(ctx, registry as unknown as AgentPtyRegistry, () => '/cwd')
return { captured, registry, dispose: () => { dispose(); return disposeCount } }
}
/** The captured definition for one tool name. */
function toolOf(captured: CapturedTool[], name: string): ToolDefinition {
const found = captured.find(candidate => candidate.definition.name === name)
if (found === undefined) throw new Error(`tool "${name}" was not registered`)
return found.definition
}
describe('agent terminal tools', () => {
it('registers all eight tools and the combined disposer unregisters them all', () => {
const { captured, dispose } = mount()
expect(captured.map(candidate => candidate.definition.name)).toEqual([
'terminal_create',
'terminal_list',
'terminal_send',
'terminal_read',
'terminal_wait_for',
'terminal_resize',
'terminal_signal',
'terminal_close',
])
expect(dispose()).toBe(8)
// Disposing twice must not throw (each registry disposer is idempotent).
expect(() => mount().dispose()).not.toThrow()
})
it('terminal_create returns the uuid and echoed title (schema-valid)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_create')
const value = await tool.execute({ title: 'dev server', command: 'npm run dev' }, exec('s1'))
expect(value).toMatchObject({ title: 'dev server' })
expect(typeof (value as { uuid: unknown }).uuid).toBe('string')
expect(registry.terminals.size).toBe(1)
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_list output matches the declared schema — no sessionId leaks', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_list')
const uuid = registry.create('s1', 'dev server', 'npm run dev')
registry.create('s2', 'other session', 'top secret')
const value = await tool.execute({}, exec('s1'))
const list = value as Array<Record<string, unknown>>
expect(list).toHaveLength(1)
expect(list[0]!.uuid).toBe(uuid)
// The session id is registry-internal ownership, never serialized.
expect('sessionId' in list[0]!).toBe(false)
// And the whole value validates against the declared output schema —
// this is the contract the registry enforces at runtime (additionalProperties: false).
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('rejects a uuid owned by another session (send/read/resize/signal/close/wait_for)', async () => {
const { captured, registry } = mount()
const mine = registry.create('s1', 'mine', '')
const foreign = registry.create('s2', 'theirs', '')
for (const name of ['terminal_send', 'terminal_read', 'terminal_resize', 'terminal_signal', 'terminal_close', 'terminal_wait_for']) {
const tool = toolOf(captured, name)
const args = name === 'terminal_send' ? { uuid: foreign, text: 'ls' }
: name === 'terminal_read' ? { uuid: foreign }
: name === 'terminal_resize' ? { uuid: foreign, cols: 80, rows: 24 }
: name === 'terminal_signal' ? { uuid: foreign, signal: 'SIGINT' }
: name === 'terminal_close' ? { uuid: foreign }
: { uuid: foreign, needle: 'x', timeout_ms: 100 }
// A foreign uuid is indistinguishable from an unknown one.
await expect(tool.execute(args, exec('s1'))).rejects.toThrow(/not found/)
}
// Own-session uuids still work.
const sendTool = toolOf(captured, 'terminal_send')
await expect(sendTool.execute({ uuid: mine, text: 'ls' }, exec('s1'))).resolves.toBeDefined()
const closeTool = toolOf(captured, 'terminal_close')
await expect(closeTool.execute({ uuid: mine }, exec('s1'))).resolves.toMatchObject({ closed: true })
})
it('terminal_read truncates at the byte cap and reports it (schema-valid)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_read')
const uuid = registry.create('s1', 'reader', '')
const value = await tool.execute({ uuid }, exec('s1'))
const result = value as { text: string; truncated: boolean }
expect(result.truncated).toBe(true)
expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual(256 * 1024)
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_resize echoes the registry-applied dimensions (schema-valid)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_resize')
const uuid = registry.create('s1', 'resizer', '')
const value = await tool.execute({ uuid, cols: 120, rows: 40 }, exec('s1'))
expect(value).toEqual({ uuid, cols: 120, rows: 40 })
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_wait_for returns the registry result in camelCase (schema-valid, no projection)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_wait_for')
const uuid = registry.create('s1', 'waiter', '')
const value = await tool.execute({ uuid, needle: 'done' }, exec('s1'))
expect(value).toEqual({ kind: 'found', needle: 'done', line: 0, column: 0, elapsedMs: 1 })
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_wait_for rejects an empty needle through the registry (no duplicate check)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_wait_for')
const uuid = registry.create('s1', 'waiter', '')
await expect(tool.execute({ uuid, needle: '' }, exec('s1'))).rejects.toThrow(/non-empty/)
})
})
describe('boundBytes', () => {
it('never splits a multi-byte UTF-8 sequence at the cap', () => {
// '€' is 3 bytes (E2 82 AC); the cap must retreat to a sequence boundary.
expect(boundBytes('a€b', 2)).toEqual({ text: 'a', truncated: true })
expect(boundBytes('a€b', 3)).toEqual({ text: 'a', truncated: true })
expect(boundBytes('a€b', 4)).toEqual({ text: 'a€', truncated: true })
expect(boundBytes('a€b', 5)).toEqual({ text: 'a€b', truncated: false })
expect(boundBytes('€', 1)).toEqual({ text: '', truncated: true })
// ASCII pages truncate at the byte cap directly.
expect(boundBytes('hello', 3)).toEqual({ text: 'hel', truncated: true })
})
it('every retained prefix decodes without U+FFFD', () => {
for (let n = 1; n <= 18; n += 1) {
const { text } = boundBytes('中文字符串测试 123', n)
expect(text.includes('\uFFFD')).toBe(false)
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(n)
}
})
})
+293 -13
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import {
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
moveTab, moveTabToEdge, openTab, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
type SidebarState, type SplitNode,
moveTab, moveTabToEdge, openDiffTab, openTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts'
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
@@ -89,12 +90,125 @@ describe('git parsing', () => {
])
})
it('parses log rows with unit separators', () => {
const rows = parseLogLines('abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\n')
it('parses log rows with unit separators (full hash + refs)', () => {
const rows = parseLogLines(
'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n'
+ 'def5678\x1fSecond subject\x1fBob\x1f2024-01-02 10:00:00 +0800\x1fdef5678abc1234def5678abc1234def5678abc1234\x1f\n',
)
expect(rows).toEqual([
{ hash: 'abc1234', subject: 'First subject', author: 'Alice', date: '2024-01-01 10:00:00 +0800' },
{
hash: 'abc1234',
subject: 'First subject',
author: 'Alice',
date: '2024-01-01 10:00:00 +0800',
hashFull: 'abc1234def5678abc1234def5678abc1234def5678',
refs: 'HEAD -> main, origin/main',
},
{
hash: 'def5678',
subject: 'Second subject',
author: 'Bob',
date: '2024-01-02 10:00:00 +0800',
hashFull: 'def5678abc1234def5678abc1234def5678abc1234',
refs: '',
},
])
})
it('parses a multi-file unified diff with aligned line numbers', () => {
const diff = [
'diff --git a/src/a.ts b/src/a.ts',
'index 1234567..89abcde 100644',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1,4 +1,5 @@ section with @@ inside',
' line1',
'-line2',
'+line2b',
' context',
'+trailing',
'diff --git a/README.md b/README.md',
'new file mode 100644',
'index 0000000..1234567',
'--- /dev/null',
'+++ b/README.md',
'@@ -0,0 +1,2 @@',
'+hello',
'+world',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
const first = parsed.files[0]!
expect(first.oldPath).toBe('a/src/a.ts')
expect(first.newPath).toBe('b/src/a.ts')
expect(first.binary).toBe(false)
expect(first.hunks).toHaveLength(1)
expect(first.hunks[0]!.oldStart).toBe(1)
expect(first.hunks[0]!.newStart).toBe(1)
expect(first.hunks[0]!.header).toBe(' section with @@ inside')
expect(first.hunks[0]!.lines).toEqual([
{ kind: 'ctx', text: 'line1', oldNum: 1, newNum: 1 },
{ kind: 'del', text: 'line2', oldNum: 2, newNum: null },
{ kind: 'add', text: 'line2b', oldNum: null, newNum: 2 },
{ kind: 'ctx', text: 'context', oldNum: 3, newNum: 3 },
{ kind: 'add', text: 'trailing', oldNum: null, newNum: 4 },
])
const second = parsed.files[1]!
expect(second.oldPath).toBe('/dev/null')
expect(second.hunks[0]!.lines[0]).toEqual({ kind: 'add', text: 'hello', oldNum: null, newNum: 1 })
expect(second.hunks[0]!.lines[1]).toEqual({ kind: 'add', text: 'world', oldNum: null, newNum: 2 })
})
it('parses binary, deletion and no-newline markers', () => {
const diff = [
'diff --git a/img.png b/img.png',
'index 111..222 100644',
'Binary files a/img.png and b/img.png differ',
'diff --git a/gone.ts b/gone.ts',
'deleted file mode 100644',
'--- a/gone.ts',
'+++ /dev/null',
'@@ -1,2 +0,0 @@',
'-one',
'-two',
'\\ No newline at end of file',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.binary).toBe(true)
expect(parsed.files[0]!.hunks).toHaveLength(0)
const gone = parsed.files[1]!
expect(gone.newPath).toBe('/dev/null')
expect(gone.hunks[0]!.lines).toEqual([
{ kind: 'del', text: 'one', oldNum: 1, newNum: null },
{ kind: 'del', text: 'two', oldNum: 2, newNum: null },
{ kind: 'meta', text: ' No newline at end of file', oldNum: null, newNum: null },
])
})
it('keeps mode/rename-only sections hunkless', () => {
const parsed = parseUnifiedDiff([
'diff --git a/run.sh b/run.sh',
'old mode 100644',
'new mode 100755',
'diff --git a/old.ts b/new.ts',
'similarity index 90%',
'rename from old.ts',
'rename to new.ts',
'',
].join('\n'))
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.oldPath).toBe('')
expect(parsed.files[0]!.hunks).toHaveLength(0)
expect(parsed.files[1]!.hunks).toHaveLength(0)
})
it('parses an empty or junk diff into no files', () => {
expect(parseUnifiedDiff('').files).toEqual([])
expect(parseUnifiedDiff('no diff here\n').files).toEqual([])
})
})
describe('sidebar state', () => {
@@ -119,6 +233,109 @@ describe('sidebar state', () => {
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1'])
})
const diffTab = (id: string): SidebarTab => ({
id,
type: 'diff',
title: id,
diff: { kind: 'worktree', path: 'src/a.ts', staged: false },
})
it('first diff splits the source pane vertically (diff below)', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTab(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
expect(after.splits.kind).toBe('split')
const split = after.splits as { dir: string; children: { kind: string; tabs?: SidebarTab[]; id: string }[] }
expect(split.dir).toBe('col')
expect(split.children).toHaveLength(2)
// The source stays on TOP (first child), the diff lands in the new bottom leaf.
expect(split.children[0]!.id).toBe(sourcePane)
expect(split.children[1]!.tabs?.map(tab => tab.id)).toEqual(['diff:w:u:src/a.ts'])
expect(after.activePane).toBe(split.children[1]!.id)
})
it('reopening the same diff focuses its existing tab', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTab(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts'))
// No new panes, no duplicate tabs.
expect(second.splits.kind).toBe('split')
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
const allTabs = split.children.flatMap(child => child.tabs ?? [])
expect(allTabs.filter(tab => tab.type === 'diff')).toHaveLength(1)
})
it('subsequent diffs stack into the existing diff pane', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTab(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678'))
// Still one split: the second diff joins the bottom leaf instead of splitting again.
expect(second.splits.kind).toBe('split')
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
const diffLeaves = split.children.filter(child => child.tabs?.some(tab => tab.type === 'diff'))
expect(diffLeaves).toHaveLength(1)
expect(diffLeaves[0]!.tabs?.map(tab => tab.id)).toEqual([
'diff:w:u:src/a.ts',
'diff:c:abc1234def5678abc1234def5678abc1234def5678',
])
})
it('openDiffTab degrades to a regular open when the source pane is gone', () => {
const s = state()
const after = openDiffTab(s, 'pane:gone', diffTab('diff:w:u:src/a.ts'))
expect(after.splits.kind).toBe('leaf')
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(tab => tab.id)).toContain('diff:w:u:src/a.ts')
})
it('sanitize drops diff tabs (ephemeral, like VSCode diff editors)', () => {
const valid = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'd1',
tabs: [
{ id: 'explorer-tab', type: 'explorer', title: 'Explorer' },
{ id: 'd1', type: 'diff', title: 'a.ts', diff: { kind: 'worktree', path: 'src/a.ts', staged: false } },
],
},
})
expect(valid?.splits.kind).toBe('leaf')
const tabs = (valid?.splits as { tabs: SidebarTab[] }).tabs
expect(tabs.map(tab => tab.id)).toEqual(['explorer-tab'])
// The dropped diff tab was the active one: the leaf falls back to a null
// active instead of resetting the whole state.
expect((valid?.splits as { active: string | null }).active).toBeNull()
// A leaf of ONLY diff tabs survives as an empty pane (welcome cards).
const onlyDiff = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'd1',
tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }],
},
})
expect(onlyDiff?.splits.kind).toBe('leaf')
expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([])
})
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
let s = state()
s = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
@@ -253,6 +470,62 @@ describe('sidebar state', () => {
})
})
describe('agent terminal reconciliation', () => {
it('adds tabs for new agent terminals', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'dev server' },
{ uuid: 'bbb-222', title: 'python repl' },
])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// The titles are preserved.
const tabs = allLeaves(s.splits).flatMap(leaf => leaf.tabs)
const agentTab = tabs.find(t => t.id === 'agent:aaa-111')
expect(agentTab?.title).toBe('dev server')
expect(agentTab?.type).toBe('terminal')
})
it('removes tabs for agent terminals that vanished from the server list', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'keep' },
{ uuid: 'bbb-222', title: 'remove' },
])
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// Next push drops bbb-222.
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'keep' }])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
})
it('is a no-op (same reference) when the lists already match', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
expect(next).toBe(s)
})
it('does not touch non-agent terminal tabs', () => {
let s = makeDefaultState(400, true)
s = openTab(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }])
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
})
it('handles an empty server list (removes all agent tabs)', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'a' },
{ uuid: 'bbb-222', title: 'b' },
])
s = reconcileAgentTerminals(s, [])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(false)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
})
})
describe('pty helpers', () => {
it('falls back from an empty SHELL to a usable shell', () => {
const previous = process.env.SHELL
@@ -546,29 +819,36 @@ describe('side card preferences', () => {
})
it('parses a valid value and clamps the percent into the contract range', async () => {
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false })))
.toEqual({ openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false })
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false, agentTerminalTools: true })))
.toEqual({ openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false, agentTerminalTools: true })
})
it('falls back per-field when a stored field is malformed', async () => {
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no' })))
.toEqual({ openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true })
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
.toEqual({ openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true, agentTerminalTools: false })
})
it('defaults autoOpenSubagent to true when the stored value is absent or malformed', async () => {
it('defaults autoOpenSubagent to true and agentTerminalTools to false when the stored value is absent or malformed', async () => {
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
.toEqual({ openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true })
.toEqual({ openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true, agentTerminalTools: false })
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
.toBe(true)
// The terminal-tools feature is OFF by default; only an explicit true turns it on.
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentTerminalTools)
.toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: 1 }))).agentTerminalTools)
.toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
.toBe(true)
})
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true })
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true })
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)