Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
069c754877 | ||
|
|
feea87557c | ||
|
|
8edfbfde44 | ||
|
|
6f62d19905 |
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+20
-11
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)) }}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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', {}),
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())}`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user