From 2f33293b2cd6861703f3c8d84362d8458d778956 Mon Sep 17 00:00:00 2001 From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:50:49 +0800 Subject: [PATCH 01/43] =?UTF-8?q?feat(cli):=20=E6=B7=BB=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E6=8F=92=E6=8B=94=E7=8A=B6=E6=80=81=E6=A0=8F=20(statusline)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在输入框下方渲染由用户配置的状态栏,通过 settings.json#statusline 声明 command/module 两类 provider,无需改动 CLI 源码即可扩展。 - core: 新增 StatusLineProviderConfig/Settings 类型与 normalize/merge 逻辑, resolveSettings 返回 ResolvedStatusLineSettings - cli: 新增 statusline manager、command-provider、module-provider、sanitize; useStatusLine hook;App.tsx 组装 SessionInfo(含 model/thinking/context/ toolUsage);PromptInput.tsx 末尾渲染分段 - docs: 新增 statusline.md / statusline_en.md,configuration 表格补字段 - .deepcode/plugins: 提供 model-info / cwd / git-branch / session-stats / tool-usage 五个示例 mjs - eslint: 为 .deepcode/plugins/**/*.mjs 添加 Node 环境 globals - tests: 新增 statusline.test.ts,覆盖 normalize / merge / manager --- .deepcode/plugins/cwd.mjs | 7 + .deepcode/plugins/git-branch.mjs | 16 ++ .deepcode/plugins/model-info.mjs | 13 + .deepcode/plugins/session-stats.mjs | 22 ++ .deepcode/plugins/tool-usage.mjs | 23 ++ docs/configuration.md | 1 + docs/configuration_en.md | 1 + docs/statusline.md | 149 +++++++++++ docs/statusline_en.md | 149 +++++++++++ eslint.config.mjs | 10 + packages/cli/src/tests/statusline.test.ts | 249 ++++++++++++++++++ packages/cli/src/ui/hooks/index.ts | 2 + packages/cli/src/ui/hooks/useStatusLine.ts | 47 ++++ .../cli/src/ui/statusline/command-provider.ts | 94 +++++++ packages/cli/src/ui/statusline/index.ts | 4 + packages/cli/src/ui/statusline/manager.ts | 169 ++++++++++++ .../cli/src/ui/statusline/module-provider.ts | 91 +++++++ packages/cli/src/ui/statusline/sanitize.ts | 21 ++ packages/cli/src/ui/statusline/types.ts | 39 +++ packages/cli/src/ui/views/App.tsx | 60 +++++ packages/cli/src/ui/views/PromptInput.tsx | 17 ++ packages/core/src/index.ts | 3 + packages/core/src/settings.ts | 146 ++++++++++ 23 files changed, 1333 insertions(+) create mode 100644 .deepcode/plugins/cwd.mjs create mode 100644 .deepcode/plugins/git-branch.mjs create mode 100644 .deepcode/plugins/model-info.mjs create mode 100644 .deepcode/plugins/session-stats.mjs create mode 100644 .deepcode/plugins/tool-usage.mjs create mode 100644 docs/statusline.md create mode 100644 docs/statusline_en.md create mode 100644 packages/cli/src/tests/statusline.test.ts create mode 100644 packages/cli/src/ui/hooks/useStatusLine.ts create mode 100644 packages/cli/src/ui/statusline/command-provider.ts create mode 100644 packages/cli/src/ui/statusline/index.ts create mode 100644 packages/cli/src/ui/statusline/manager.ts create mode 100644 packages/cli/src/ui/statusline/module-provider.ts create mode 100644 packages/cli/src/ui/statusline/sanitize.ts create mode 100644 packages/cli/src/ui/statusline/types.ts diff --git a/.deepcode/plugins/cwd.mjs b/.deepcode/plugins/cwd.mjs new file mode 100644 index 00000000..d36099b4 --- /dev/null +++ b/.deepcode/plugins/cwd.mjs @@ -0,0 +1,7 @@ +export default function cwdProvider({ projectRoot }) { + const cwd = process.cwd() || projectRoot || ""; + if (!cwd) return ""; + const home = process.env.HOME || process.env.USERPROFILE || ""; + const display = home && cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd; + return display; +} diff --git a/.deepcode/plugins/git-branch.mjs b/.deepcode/plugins/git-branch.mjs new file mode 100644 index 00000000..b5096073 --- /dev/null +++ b/.deepcode/plugins/git-branch.mjs @@ -0,0 +1,16 @@ +import { execFileSync } from "node:child_process"; + +export default function gitBranchProvider({ projectRoot }) { + try { + const out = execFileSync("git", ["branch", "--show-current"], { + cwd: projectRoot || process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1500, + }).trim(); + if (!out) return ""; + return `git:${out}`; + } catch { + return ""; + } +} diff --git a/.deepcode/plugins/model-info.mjs b/.deepcode/plugins/model-info.mjs new file mode 100644 index 00000000..71a4ec09 --- /dev/null +++ b/.deepcode/plugins/model-info.mjs @@ -0,0 +1,13 @@ +export default function modelInfoProvider({ session }) { + if (!session) return ""; + const parts = []; + if (session.model) { + parts.push(session.model); + } + if (session.thinkingEnabled && session.reasoningEffort) { + parts.push(`thinking:${session.reasoningEffort}`); + } else if (session.thinkingEnabled) { + parts.push("thinking"); + } + return parts.join(" "); +} diff --git a/.deepcode/plugins/session-stats.mjs b/.deepcode/plugins/session-stats.mjs new file mode 100644 index 00000000..79aca645 --- /dev/null +++ b/.deepcode/plugins/session-stats.mjs @@ -0,0 +1,22 @@ +function formatTokens(n) { + if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k"; + return String(n); +} + +export default function sessionStatsProvider({ session }) { + if (!session || !session.activeSessionId) { + return "no session"; + } + const parts = []; + parts.push(`msgs:${session.messageCount}`); + if (session.requestCount > 0) { + parts.push(`reqs:${session.requestCount}`); + } + if (session.activeTokens > 0 && session.maxContextTokens > 0) { + const pct = Math.round((session.activeTokens / session.maxContextTokens) * 100); + parts.push(`ctx:${formatTokens(session.activeTokens)}/${formatTokens(session.maxContextTokens)} ${pct}%`); + } else if (session.totalTokens > 0) { + parts.push(`tokens:${formatTokens(session.totalTokens)}`); + } + return parts.join(" "); +} diff --git a/.deepcode/plugins/tool-usage.mjs b/.deepcode/plugins/tool-usage.mjs new file mode 100644 index 00000000..212fe45e --- /dev/null +++ b/.deepcode/plugins/tool-usage.mjs @@ -0,0 +1,23 @@ +const TOOL_ORDER = ["bash", "edit", "read", "write", "AskUserQuestion", "UpdatePlan", "WebSearch"]; + +export default function toolUsageProvider({ session }) { + if (!session || !session.activeSessionId) { + return ""; + } + const usage = session.toolUsage; + if (!usage || Object.keys(usage).length === 0) { + return ""; + } + // Sort: preferred order first, then by count desc + const sorted = Object.entries(usage).sort((a, b) => { + const ai = TOOL_ORDER.indexOf(a[0]); + const bi = TOOL_ORDER.indexOf(b[0]); + if (ai !== -1 && bi !== -1) return ai - bi; + if (ai !== -1) return -1; + if (bi !== -1) return 1; + return b[1] - a[1]; + }); + + const shortNames = sorted.slice(0, 6); + return shortNames.map(([name, count]) => `${name}×${count}`).join(" "); +} diff --git a/docs/configuration.md b/docs/configuration.md index 2f198b10..d942dc04 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,6 +37,7 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 | `mcpServers` | object | MCP 服务器配置(键为服务名,值为 McpServerConfig 对象) | | `temperature` | number | 模型采样温度,范围 `0` 到 `2` | | `enabledSkills` | object | 按 skill 名称启用或禁用 skill 的配置 | +| `statusline` | object | 状态栏插件配置(参见 [statusline.md](./statusline.md)) | #### `env` 子字段 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index fac8c349..a078c428 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -37,6 +37,7 @@ The following are all the top-level fields supported in `settings.json`, along w | `mcpServers` | object | MCP server configurations (keys are service names, values are McpServerConfig objects) | | `temperature` | number | Sampling temperature for LLM, from `0` to `2` | | `enabledSkills` | object | Per-skill enable/disable map, keyed by skill name | +| `statusline` | object | Status line plugins (see [statusline_en.md](./statusline_en.md)) | #### `env` Sub-fields diff --git a/docs/statusline.md b/docs/statusline.md new file mode 100644 index 00000000..4ab8a11d --- /dev/null +++ b/docs/statusline.md @@ -0,0 +1,149 @@ +# 状态栏插件 + +Deep Code CLI 支持通过插件向终端底部状态栏注入自定义信息(Git 分支、当前时间、token 用量等),无需修改 CLI 源码。状态栏行展示在输入框下方的快捷键提示行下方,所有 provider 的输出会用分隔符拼接后渲染。 + +## 配置 + +在 `~/.deepcode/settings.json`(或项目级 `.deepcode/settings.json`)中添加 `statusline` 字段: + +```jsonc +{ + "statusline": { + "enabled": true, + "refreshMs": 2000, + "separator": " · ", + "providers": [ + { + "type": "command", + "id": "git", + "command": "git branch --show-current", + "color": "cyan" + }, + { + "type": "module", + "id": "tokens", + "path": "./.deepcode/plugins/tokens.mjs", + "color": "yellow" + } + ] + } +} +``` + +### 字段 + +| 字段 | 类型 | 说明 | +| ------------- | --------- | ------------------------------------------------------------------- | +| `enabled` | boolean | 是否启用。省略时,只要存在至少一个 provider 即视为启用 | +| `refreshMs` | number | 拉取间隔毫秒。最小 500,默认 2000 | +| `separator` | string | 多个 provider 输出之间的分隔符,默认 `" · "` | +| `providers` | array | provider 列表,按声明顺序渲染 | + +## Provider 类型 + +### `command` —— 执行外部命令 + +每隔 `refreshMs` 在 shell 中执行一次命令,取 stdout 第一行作为状态栏 segment。 + +| 字段 | 类型 | 必填 | 说明 | +| ----------- | ------- | ---- | ----------------------------------------------------------------- | +| `type` | string | 是 | 固定为 `"command"` | +| `command` | string | 是 | shell 命令(支持管道、重定向等) | +| `id` | string | 否 | 唯一标识。省略时按下标自动生成 | +| `cwd` | string | 否 | 执行目录。相对路径相对于项目根目录,省略时使用项目根目录 | +| `timeoutMs` | number | 否 | 超时毫秒,默认 1500。超时返回空串 | +| `color` | string | 否 | ink 支持的颜色名(如 `"red"`、`"#229ac3"`) | + +示例: + +```json +{ "type": "command", "id": "git", "command": "git status -sb | head -1" } +{ "type": "command", "id": "time", "command": "date +%H:%M" } +{ "type": "command", "id": "node", "command": "node -v", "color": "green" } +``` + +### `module` —— 加载 JS 模块 + +加载本地 JS/MJS 模块,调用其默认导出函数,把返回值作为 segment 文本。 + +| 字段 | 类型 | 必填 | 说明 | +| ----------- | ------- | ---- | ----------------------------------------------------------------------------------- | +| `type` | string | 是 | 固定为 `"module"` | +| `path` | string | 是 | 模块路径。相对路径相对于项目根目录 | +| `id` | string | 否 | 唯一标识 | +| `timeoutMs` | number | 否 | 超时毫秒,默认 2000 | +| `color` | string | 否 | ink 支持的颜色 | + +模块需导出一个 `default` 函数(或具名 `provider`): + +```js +// .deepcode/plugins/tokens.mjs +export default function tokensProvider({ projectRoot, session }) { + // 返回字符串(同步或异步) + if (session?.activeSessionId) { + return `msgs:${session.messageCount} reqs:${session.requestCount} tokens:${session.totalTokens}`; + } + return `tokens: 1.2k`; +} +``` + +函数接收一个对象 `{ projectRoot: string, session: SessionInfo | null }`,返回 `string` 或 `Promise`。 + +`SessionInfo` 结构: + +| 字段 | 类型 | 说明 | +| ----------------- | ------------------ | --------------------------------------------------- | +| `activeSessionId` | `string \| null` | 当前活跃会话的 ID,无会话时为 `null` | +| `messageCount` | `number` | 当前会话中的消息总数 | +| `requestCount` | `number` | 当前会话中的 LLM API 请求次数 | +| `totalTokens` | `number` | 当前会话中消耗的 token 总数 | + +## 安全限制 + +- **module provider 路径必须位于项目根目录或用户家目录之下**,绝对路径在这两个范围之外会被拒绝加载(防止从任意位置执行代码)。 +- 单个 segment 文本被自动: + - 取第一个非空行 + - 去除 ANSI 转义序列 + - 折叠空白字符 + - 截断到 40 个字符(超出加 `…`) +- command provider 的 stdout 最多读取 4 KB。 +- 任何 provider 抛错、超时、或返回空字符串,**只跳过该 segment**,不影响其它 provider。 + +## 行为 + +- 启动 CLI 后立即触发一次拉取,之后按 `refreshMs` 周期刷新。 +- 用户级与项目级配置的 `providers` 数组会**合并**(用户先、项目后);其他字段以项目级为优先。 +- 状态栏行在任何场景下都显示(包括 busy、permission prompt 等),不影响 busy 提示。 +- 修改配置文件后需重启 CLI 生效(不会热加载)。 + +## 完整示例 + +```json +{ + "statusline": { + "enabled": true, + "refreshMs": 3000, + "providers": [ + { + "type": "command", + "id": "branch", + "command": "git branch --show-current", + "color": "cyan" + }, + { + "type": "command", + "id": "dirty", + "command": "git status --porcelain | wc -l | xargs -I{} echo '{} files changed'", + "color": "yellow" + }, + { + "type": "module", + "id": "ts-errors", + "path": "./.deepcode/plugins/ts-errors.mjs", + "color": "red", + "timeoutMs": 5000 + } + ] + } +} +``` diff --git a/docs/statusline_en.md b/docs/statusline_en.md new file mode 100644 index 00000000..bd14d91a --- /dev/null +++ b/docs/statusline_en.md @@ -0,0 +1,149 @@ +# Status Line Plugins + +Deep Code CLI lets you inject custom information into the status line at the bottom of the terminal (Git branch, current time, token usage, etc.) through plugins, without modifying the CLI source. The status line renders below the keyboard hint line under the prompt input, and all provider outputs are concatenated with a separator. + +## Configuration + +Add a `statusline` field to `~/.deepcode/settings.json` (or the project-level `.deepcode/settings.json`): + +```jsonc +{ + "statusline": { + "enabled": true, + "refreshMs": 2000, + "separator": " · ", + "providers": [ + { + "type": "command", + "id": "git", + "command": "git branch --show-current", + "color": "cyan" + }, + { + "type": "module", + "id": "tokens", + "path": "./.deepcode/plugins/tokens.mjs", + "color": "yellow" + } + ] + } +} +``` + +### Fields + +| Field | Type | Description | +| ------------- | --------- | ---------------------------------------------------------------------------- | +| `enabled` | boolean | Whether the status line is enabled. If omitted, defaults to true when at least one provider is configured. | +| `refreshMs` | number | Refresh interval in milliseconds. Minimum 500, default 2000. | +| `separator` | string | Separator between provider outputs. Default `" · "`. | +| `providers` | array | List of providers, rendered in declaration order. | + +## Provider Types + +### `command` — Run an External Command + +Executes a shell command every `refreshMs` and uses the first line of stdout as the status segment. + +| Field | Type | Required | Description | +| ----------- | ------- | -------- | ------------------------------------------------------------------------ | +| `type` | string | Yes | Must be `"command"`. | +| `command` | string | Yes | Shell command (supports pipes, redirection, etc.). | +| `id` | string | No | Unique identifier. Auto-generated from index if omitted. | +| `cwd` | string | No | Working directory. Relative paths resolved against the project root. | +| `timeoutMs` | number | No | Timeout in milliseconds. Default 1500. Empty string on timeout. | +| `color` | string | No | Ink-supported color (e.g. `"red"`, `"#229ac3"`). | + +Examples: + +```json +{ "type": "command", "id": "git", "command": "git status -sb | head -1" } +{ "type": "command", "id": "time", "command": "date +%H:%M" } +{ "type": "command", "id": "node", "command": "node -v", "color": "green" } +``` + +### `module` — Load a JS Module + +Loads a local JS/MJS module and calls its default-exported function. The return value becomes the segment text. + +| Field | Type | Required | Description | +| ----------- | ------- | -------- | ------------------------------------------------------------------------------------ | +| `type` | string | Yes | Must be `"module"`. | +| `path` | string | Yes | Module path. Relative paths resolved against the project root. | +| `id` | string | No | Unique identifier. | +| `timeoutMs` | number | No | Timeout in milliseconds. Default 2000. | +| `color` | string | No | Ink-supported color. | + +The module must export a `default` function (or a named `provider`): + +```js +// .deepcode/plugins/tokens.mjs +export default function tokensProvider({ projectRoot, session }) { + // Return a string (sync or async). + if (session?.activeSessionId) { + return `msgs:${session.messageCount} reqs:${session.requestCount} tokens:${session.totalTokens}`; + } + return `tokens: 1.2k`; +} +``` + +The function receives `{ projectRoot: string, session: SessionInfo | null }` and returns `string` or `Promise`. + +`SessionInfo` shape: + +| Field | Type | Description | +| ----------------- | ------------------- | ---------------------------------------------------------- | +| `activeSessionId` | `string \| null` | ID of the currently active session, or `null` if none. | +| `messageCount` | `number` | Total messages in the active session. | +| `requestCount` | `number` | Total LLM API requests made in the active session. | +| `totalTokens` | `number` | Total tokens consumed in the active session. | + +## Safety Constraints + +- **Module provider paths must reside within the project root or the user's home directory**; absolute paths outside both are rejected (to prevent loading arbitrary code). +- Each segment's text is automatically: + - Reduced to the first non-empty line + - Stripped of ANSI escape sequences + - Whitespace-collapsed + - Truncated to 40 characters (with `…` for overflow) +- Command provider stdout is capped at 4 KB. +- If any provider throws, times out, or returns an empty string, **only that segment is skipped**; the rest are unaffected. + +## Behavior + +- The first refresh fires immediately after CLI startup, then on the configured interval. +- The `providers` arrays from user-level and project-level configs are **merged** (user first, project second); other fields prefer the project-level value. +- The status line is shown in every state (including busy and permission prompts) without interfering with busy indicators. +- Changes to config require a CLI restart (no hot reload). + +## Full Example + +```json +{ + "statusline": { + "enabled": true, + "refreshMs": 3000, + "providers": [ + { + "type": "command", + "id": "branch", + "command": "git branch --show-current", + "color": "cyan" + }, + { + "type": "command", + "id": "dirty", + "command": "git status --porcelain | wc -l | xargs -I{} echo '{} files changed'", + "color": "yellow" + }, + { + "type": "module", + "id": "ts-errors", + "path": "./.deepcode/plugins/ts-errors.mjs", + "color": "red", + "timeoutMs": 5000 + } + ] + } +} +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index b9ec5dc1..abeb8390 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -65,6 +65,16 @@ export default tseslint.config( }, }, }, + // Statusline plugins: Node.js environment + { + files: [".deepcode/plugins/**/*.mjs", ".deepcode/plugins/**/*.js"], + languageOptions: { + globals: { + process: "readonly", + console: "readonly", + }, + }, + }, // Browser resources: VSCode webview scripts { files: ["packages/*/resources/**/*.js"], diff --git a/packages/cli/src/tests/statusline.test.ts b/packages/cli/src/tests/statusline.test.ts new file mode 100644 index 00000000..fffc15a1 --- /dev/null +++ b/packages/cli/src/tests/statusline.test.ts @@ -0,0 +1,249 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { sanitizeStatusText, STATUS_SEGMENT_MAX_LENGTH } from "../ui/statusline/sanitize"; +import { validateModulePath, loadModuleProvider } from "../ui/statusline/module-provider"; +import { createCommandStatusProvider } from "../ui/statusline/command-provider"; +import { StatusLineManager } from "../ui/statusline/manager"; +import { resolveSettings } from "@vegamo/deepcode-core"; +import type { ResolvedStatusLineSettings } from "@vegamo/deepcode-core"; + +test("sanitizeStatusText returns empty for null/undefined/empty", () => { + assert.equal(sanitizeStatusText(undefined), ""); + assert.equal(sanitizeStatusText(null), ""); + assert.equal(sanitizeStatusText(""), ""); +}); + +test("sanitizeStatusText keeps first non-empty line and strips ANSI", () => { + assert.equal(sanitizeStatusText("\n\nfirst\nsecond"), "first"); + assert.equal(sanitizeStatusText("red text"), "red text"); + assert.equal(sanitizeStatusText("multiple spaces\t\there"), "multiple spaces here"); +}); + +test("sanitizeStatusText truncates to max length with ellipsis", () => { + const long = "x".repeat(STATUS_SEGMENT_MAX_LENGTH + 20); + const result = sanitizeStatusText(long); + assert.equal(result.length, STATUS_SEGMENT_MAX_LENGTH); + assert.ok(result.endsWith("…")); +}); + +test("sanitizeStatusText respects custom max length", () => { + assert.equal(sanitizeStatusText("hello world", 5), "hell…"); + assert.equal(sanitizeStatusText("hi", 5), "hi"); +}); + +test("validateModulePath accepts paths under project root", () => { + const projectRoot = path.resolve(os.tmpdir(), "deepcode-test-project"); + const inside = path.join(projectRoot, "plugins", "status.js"); + const result = validateModulePath(inside, projectRoot); + assert.equal(result, path.normalize(inside)); +}); + +test("validateModulePath accepts relative paths resolved under project root", () => { + const projectRoot = path.resolve(os.tmpdir(), "deepcode-test-project"); + const result = validateModulePath("plugins/status.js", projectRoot); + assert.equal(result, path.normalize(path.join(projectRoot, "plugins", "status.js"))); +}); + +test("validateModulePath rejects paths outside project root and home", () => { + const projectRoot = path.resolve(os.tmpdir(), "deepcode-isolated-test"); + // Use a path guaranteed to be outside both projectRoot and HOME. + const outside = path.resolve("/totally-not-in-any-allowed-base/status.js"); + const result = validateModulePath(outside, projectRoot); + assert.equal(result, null); +}); + +test("resolveSettings produces a default statusline with no providers", () => { + const resolved = resolveSettings({}, { model: "default-model", baseURL: "https://default.example.com" }, {}); + assert.equal(resolved.statusline.enabled, false); + assert.equal(resolved.statusline.refreshMs, 2000); + assert.deepEqual(resolved.statusline.providers, []); +}); + +test("resolveSettings normalizes statusline providers and filters invalid entries", () => { + const resolved = resolveSettings( + { + statusline: { + enabled: true, + refreshMs: 3000, + providers: [ + { type: "command", id: "git", command: "git status -sb" }, + { type: "command", command: "" } as never, // invalid: empty command + { type: "module", path: "./plugins/x.js" }, + { type: "module" } as never, // invalid: missing path + { type: "unknown" } as never, // invalid: bad type + ], + }, + }, + { model: "default-model", baseURL: "https://default.example.com" }, + {} + ); + assert.equal(resolved.statusline.enabled, true); + assert.equal(resolved.statusline.refreshMs, 3000); + assert.equal(resolved.statusline.providers.length, 2); + assert.equal(resolved.statusline.providers[0]?.type, "command"); + assert.equal(resolved.statusline.providers[1]?.type, "module"); +}); + +test("resolveSettings clamps refreshMs to minimum and ignores invalid values", () => { + const tooSmall = resolveSettings({ statusline: { refreshMs: 100 } }, { model: "m", baseURL: "https://e" }, {}); + assert.equal(tooSmall.statusline.refreshMs, 2000); // falls back to default +}); + +test("createCommandStatusProvider returns stdout from short commands", async () => { + const provider = createCommandStatusProvider( + { type: "command", command: process.platform === "win32" ? "echo hello" : "printf hello" }, + process.cwd(), + "test-cmd" + ); + const ac = new AbortController(); + const result = await provider.fetch({ projectRoot: process.cwd(), signal: ac.signal }); + assert.ok(result.includes("hello")); +}); + +test("createCommandStatusProvider times out long-running commands", async () => { + const sleepCmd = process.platform === "win32" ? "ping -n 5 127.0.0.1 > nul" : "sleep 3"; + const provider = createCommandStatusProvider( + { type: "command", command: sleepCmd, timeoutMs: 200 }, + process.cwd(), + "slow" + ); + const ac = new AbortController(); + const start = Date.now(); + const result = await provider.fetch({ projectRoot: process.cwd(), signal: ac.signal }); + const elapsed = Date.now() - start; + assert.ok(elapsed < 1500, `expected timeout within ~1.5s, got ${elapsed}ms`); + assert.equal(result, ""); +}); + +test("createCommandStatusProvider returns empty on non-existent command", async () => { + const provider = createCommandStatusProvider( + { type: "command", command: "this-command-definitely-does-not-exist-xyz-abc-12345" }, + process.cwd(), + "missing" + ); + const ac = new AbortController(); + const result = await provider.fetch({ projectRoot: process.cwd(), signal: ac.signal }); + // Either empty (failure) or shell error message — both fine, just must not hang/throw. + assert.equal(typeof result, "string"); +}); + +test("loadModuleProvider returns null when the path does not exist", async () => { + const provider = await loadModuleProvider( + path.join(os.tmpdir(), "does-not-exist-xyz.mjs"), + undefined, + "missing", + 1000 + ); + assert.equal(provider, null); +}); + +test("loadModuleProvider isolates errors thrown by the user function", async () => { + // Create a temporary module that throws. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-statusline-")); + const modPath = path.join(dir, "bad.mjs"); + fs.writeFileSync(modPath, "export default () => { throw new Error('boom'); }", "utf8"); + try { + const provider = await loadModuleProvider(modPath, undefined, "bad", 1000); + assert.ok(provider, "provider should load even if its fn throws on invocation"); + const ac = new AbortController(); + await assert.rejects(provider!.fetch({ projectRoot: process.cwd(), signal: ac.signal })); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadModuleProvider succeeds for a well-formed module", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-statusline-")); + const modPath = path.join(dir, "good.mjs"); + fs.writeFileSync(modPath, "export default ({ projectRoot }) => `root=${projectRoot}`;", "utf8"); + try { + const provider = await loadModuleProvider(modPath, "yellow", "good", 1000); + assert.ok(provider); + assert.equal(provider!.color, "yellow"); + const ac = new AbortController(); + const result = await provider!.fetch({ projectRoot: "/some/root", signal: ac.signal }); + assert.equal(result, "root=/some/root"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("StatusLineManager emits segments after fetch and stops cleanly", async () => { + const config: ResolvedStatusLineSettings = { + enabled: true, + refreshMs: 60_000, + separator: " · ", + providers: [ + { + type: "command", + id: "echo", + command: process.platform === "win32" ? "echo hello" : "printf hello", + }, + ], + }; + const manager = new StatusLineManager(); + const updates: Array> = []; + const unsub = manager.subscribe((segments) => updates.push(segments.map((s) => ({ id: s.id, text: s.text })))); + await manager.start(config, process.cwd()); + + // Wait for the initial fetch to settle. + await new Promise((resolve) => setTimeout(resolve, 400)); + + unsub(); + manager.stop(); + + const populated = updates.find((u) => u.length > 0 && u[0]?.text.includes("hello")); + assert.ok(populated, `expected an update with 'hello' segment; got ${JSON.stringify(updates)}`); +}); + +test("StatusLineManager skips fetch when disabled", async () => { + const config: ResolvedStatusLineSettings = { + enabled: false, + refreshMs: 60_000, + separator: " · ", + providers: [{ type: "command", command: "echo whatever" }], + }; + const manager = new StatusLineManager(); + const updates: Array<{ id: string; text: string }[]> = []; + manager.subscribe((segs) => updates.push(segs.map((s) => ({ id: s.id, text: s.text })))); + await manager.start(config, process.cwd()); + await new Promise((resolve) => setTimeout(resolve, 100)); + manager.stop(); + assert.equal(updates.length, 0); +}); + +test("StatusLineManager isolates a failing provider from succeeding ones", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-statusline-")); + const badMod = path.join(dir, "bad.mjs"); + const goodMod = path.join(dir, "good.mjs"); + fs.writeFileSync(badMod, "export default () => { throw new Error('boom'); }", "utf8"); + fs.writeFileSync(goodMod, "export default () => 'ok';", "utf8"); + + try { + const config: ResolvedStatusLineSettings = { + enabled: true, + refreshMs: 60_000, + separator: " · ", + providers: [ + { type: "module", id: "bad", path: badMod }, + { type: "module", id: "good", path: goodMod }, + ], + }; + const manager = new StatusLineManager(); + let lastSegments: Array<{ id: string; text: string }> = []; + manager.subscribe((segs) => { + lastSegments = segs.map((s) => ({ id: s.id, text: s.text })); + }); + await manager.start(config, dir); + await new Promise((resolve) => setTimeout(resolve, 400)); + manager.stop(); + assert.equal(lastSegments.length, 1); + assert.equal(lastSegments[0]?.id, "good"); + assert.equal(lastSegments[0]?.text, "ok"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/ui/hooks/index.ts b/packages/cli/src/ui/hooks/index.ts index 226a6e98..4e6bda52 100644 --- a/packages/cli/src/ui/hooks/index.ts +++ b/packages/cli/src/ui/hooks/index.ts @@ -17,3 +17,5 @@ export type { PasteRegion, PasteHandlingState, PasteHandlingActions } from "./us export { useHistoryNavigation } from "./useHistoryNavigation"; export type { HistoryNavigationState, HistoryNavigationActions } from "./useHistoryNavigation"; + +export { useStatusLine } from "./useStatusLine"; diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts new file mode 100644 index 00000000..f84534ec --- /dev/null +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -0,0 +1,47 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ResolvedStatusLineSettings } from "@vegamo/deepcode-core"; +import { StatusLineManager } from "../statusline"; +import type { SessionInfo, StatusSegment } from "../statusline"; + +/** + * Manages a StatusLineManager lifecycle and returns the current segments. + * Starts polling when the config is enabled, stops on unmount or config change. + */ +export function useStatusLine( + config: ResolvedStatusLineSettings, + projectRoot: string, + getSessionInfo?: () => SessionInfo | null +): StatusSegment[] { + const [segments, setSegments] = useState([]); + const managerRef = useRef(null); + const getSessionInfoRef = useRef(getSessionInfo); + getSessionInfoRef.current = getSessionInfo; + + const configKey = useMemo( + () => + JSON.stringify({ + enabled: config.enabled, + refreshMs: config.refreshMs, + separator: config.separator, + providers: config.providers, + }), + [config] + ); + + useEffect(() => { + const manager = new StatusLineManager(); + managerRef.current = manager; + + const unsub = manager.subscribe(setSegments); + void manager.start(config, projectRoot, () => (getSessionInfoRef.current ? getSessionInfoRef.current() : null)); + + return () => { + unsub(); + manager.stop(); + managerRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- config tracked via configKey + }, [configKey, projectRoot]); + + return segments; +} diff --git a/packages/cli/src/ui/statusline/command-provider.ts b/packages/cli/src/ui/statusline/command-provider.ts new file mode 100644 index 00000000..fb6327d0 --- /dev/null +++ b/packages/cli/src/ui/statusline/command-provider.ts @@ -0,0 +1,94 @@ +import { spawn } from "child_process"; +import * as path from "path"; +import type { StatusLineProviderConfig } from "@vegamo/deepcode-core"; +import type { StatusProvider, StatusProviderContext } from "./types"; + +const DEFAULT_TIMEOUT_MS = 1500; +const MIN_TIMEOUT_MS = 100; +const MAX_OUTPUT_BYTES = 4096; + +function resolveTimeout(value: number | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < MIN_TIMEOUT_MS) { + return DEFAULT_TIMEOUT_MS; + } + return Math.floor(value); +} + +function resolveCwd(configCwd: string | undefined, projectRoot: string): string { + if (!configCwd) { + return projectRoot; + } + return path.isAbsolute(configCwd) ? configCwd : path.resolve(projectRoot, configCwd); +} + +export function createCommandStatusProvider( + config: Extract, + projectRoot: string, + id: string +): StatusProvider { + const timeoutMs = resolveTimeout(config.timeoutMs); + const cwd = resolveCwd(config.cwd, projectRoot); + + return { + id, + color: config.color, + maxLength: config.maxLength, + fetch: ({ signal }: StatusProviderContext) => + new Promise((resolve) => { + if (signal.aborted) { + resolve(""); + return; + } + const isWindows = process.platform === "win32"; + const child = spawn(config.command, { + cwd, + shell: isWindows ? true : "/bin/sh", + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stdoutBytes = 0; + let settled = false; + const finish = (value: string): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + if (!child.killed) { + child.kill(); + } + resolve(value); + }; + + const onAbort = (): void => finish(""); + signal.addEventListener("abort", onAbort, { once: true }); + + const timer = setTimeout(() => finish(""), timeoutMs); + + const cleanup = (): void => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + }; + + child.stdout?.on("data", (chunk: Buffer | string) => { + if (settled) { + return; + } + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + if (stdoutBytes >= MAX_OUTPUT_BYTES) { + return; + } + const remaining = MAX_OUTPUT_BYTES - stdoutBytes; + const slice = text.length > remaining ? text.slice(0, remaining) : text; + stdout += slice; + stdoutBytes += slice.length; + }); + // Drain stderr to avoid blocking, but ignore content. + child.stderr?.on("data", () => undefined); + child.on("error", () => finish("")); + child.on("close", () => finish(stdout)); + }), + }; +} diff --git a/packages/cli/src/ui/statusline/index.ts b/packages/cli/src/ui/statusline/index.ts new file mode 100644 index 00000000..1299c8ec --- /dev/null +++ b/packages/cli/src/ui/statusline/index.ts @@ -0,0 +1,4 @@ +export { StatusLineManager } from "./manager"; +export { sanitizeStatusText, STATUS_SEGMENT_MAX_LENGTH } from "./sanitize"; +export { validateModulePath } from "./module-provider"; +export type { StatusSegment, StatusProvider, StatusProviderContext, SessionInfo } from "./types"; diff --git a/packages/cli/src/ui/statusline/manager.ts b/packages/cli/src/ui/statusline/manager.ts new file mode 100644 index 00000000..4ebd8888 --- /dev/null +++ b/packages/cli/src/ui/statusline/manager.ts @@ -0,0 +1,169 @@ +import type { ResolvedStatusLineSettings, StatusLineProviderConfig } from "@vegamo/deepcode-core"; +import { sanitizeStatusText } from "./sanitize"; +import { createCommandStatusProvider } from "./command-provider"; +import { loadModuleProvider, validateModulePath } from "./module-provider"; +import type { SessionInfo, StatusProvider, StatusSegment } from "./types"; + +type SegmentsListener = (segments: StatusSegment[]) => void; + +function segmentsEqual(a: StatusSegment[], b: StatusSegment[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i]?.id !== b[i]?.id || a[i]?.text !== b[i]?.text || a[i]?.color !== b[i]?.color) { + return false; + } + } + return true; +} + +export class StatusLineManager { + private providers: StatusProvider[] = []; + private ac: AbortController | null = null; + private timer: ReturnType | null = null; + private subscribers = new Set(); + private segments: StatusSegment[] = []; + private running = false; + private projectRoot = ""; + private getSessionInfo: (() => SessionInfo | null) | undefined; + + get currentSegments(): StatusSegment[] { + return this.segments; + } + + subscribe(fn: SegmentsListener): () => void { + this.subscribers.add(fn); + return () => { + this.subscribers.delete(fn); + }; + } + + private emit(segments: StatusSegment[]): void { + if (segmentsEqual(this.segments, segments)) { + return; + } + this.segments = segments; + for (const fn of this.subscribers) { + try { + fn(segments); + } catch { + // ignore subscriber errors + } + } + } + + async start( + config: ResolvedStatusLineSettings, + projectRoot: string, + getSessionInfo?: () => SessionInfo | null + ): Promise { + if (this.running) { + this.stop(); + } + if (!config.enabled || config.providers.length === 0) { + return; + } + + this.projectRoot = projectRoot; + this.getSessionInfo = getSessionInfo; + const { providers, refreshMs } = config; + this.ac = new AbortController(); + const { signal } = this.ac; + + // Build providers + const built: StatusProvider[] = []; + let nextId = 0; + for (const entry of providers) { + const providerId = entry.id || `${entry.type}-${nextId}`; + const provider = await this.buildProvider(entry, projectRoot, providerId); + if (provider) { + built.push(provider); + } + nextId += 1; + } + + if (built.length === 0) { + return; + } + + this.providers = built; + this.running = true; + + // Fetch immediately, then on interval. + void this.fetchAll(); + this.timer = setInterval(() => { + if (signal.aborted) { + return; + } + void this.fetchAll(); + }, refreshMs); + } + + stop(): void { + this.running = false; + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + if (this.ac) { + this.ac.abort(); + this.ac = null; + } + for (const provider of this.providers) { + provider.dispose?.(); + } + this.providers = []; + this.getSessionInfo = undefined; + } + + private async buildProvider( + config: StatusLineProviderConfig, + projectRoot: string, + providerId: string + ): Promise { + if (config.type === "command") { + return createCommandStatusProvider(config, projectRoot, providerId); + } + if (config.type === "module") { + const resolvedPath = validateModulePath(config.path, projectRoot); + if (!resolvedPath) { + return null; + } + return loadModuleProvider(resolvedPath, config.color, providerId, config.timeoutMs, config.maxLength); + } + return null; + } + + private async fetchAll(): Promise { + if (!this.ac || this.ac.signal.aborted) { + return; + } + + const results = await Promise.all( + this.providers.map(async (provider) => { + try { + const text = await provider.fetch({ + projectRoot: this.projectRoot, + signal: this.ac!.signal, + getSessionInfo: this.getSessionInfo, + }); + const sanitized = sanitizeStatusText(text, provider.maxLength); + if (!sanitized) { + return null; + } + const segment: StatusSegment = { id: provider.id, text: sanitized }; + if (provider.color) { + segment.color = provider.color; + } + return segment; + } catch { + return null; + } + }) + ); + + const segments = results.filter((s): s is StatusSegment => s !== null); + this.emit(segments); + } +} diff --git a/packages/cli/src/ui/statusline/module-provider.ts b/packages/cli/src/ui/statusline/module-provider.ts new file mode 100644 index 00000000..0222bb6c --- /dev/null +++ b/packages/cli/src/ui/statusline/module-provider.ts @@ -0,0 +1,91 @@ +import * as path from "path"; +import type { StatusProvider, StatusProviderContext } from "./types"; + +const DEFAULT_TIMEOUT_MS = 2000; + +/** + * Validate that the module path is within the allowed base directory. + * Only paths under or relative to the project root or home directory are allowed. + */ +export function validateModulePath(modulePath: string, projectRoot: string): string | null { + // Resolve relative to project root first. + const resolved = path.isAbsolute(modulePath) ? modulePath : path.resolve(projectRoot, modulePath); + const normalized = path.normalize(resolved); + + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + const allowedBases = [projectRoot]; + if (homeDir) { + allowedBases.push(homeDir); + } + + for (const base of allowedBases) { + const normalizedBase = path.normalize(base); + // Check if the resolved path is under the allowed base. + if (normalized.startsWith(normalizedBase + path.sep) || normalized === normalizedBase) { + return normalized; + } + } + return null; +} + +export async function loadModuleProvider( + resolvedPath: string, + color: string | undefined, + id: string, + timeoutMs: number | undefined, + maxLength?: number +): Promise { + try { + const timeout = + typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs >= 100 + ? Math.floor(timeoutMs) + : DEFAULT_TIMEOUT_MS; + + let mod: unknown; + try { + mod = await import(resolvedPath); + } catch { + // Try with file:// protocol + const fileUrl = path.isAbsolute(resolvedPath) ? `file://${resolvedPath}` : resolvedPath; + mod = await import(fileUrl); + } + + const providerFn = (mod as Record).default ?? (mod as Record).provider; + if (typeof providerFn !== "function") { + return null; + } + + return { + id, + color, + maxLength, + fetch: async (ctx: StatusProviderContext): Promise => { + if (ctx.signal.aborted) { + return ""; + } + const result = await Promise.race([ + Promise.resolve().then(() => + providerFn({ + projectRoot: ctx.projectRoot, + session: ctx.getSessionInfo ? ctx.getSessionInfo() : null, + }) + ), + new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error("timeout")), timeout); + ctx.signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(new Error("aborted")); + }, + { once: true } + ); + }), + ]); + return typeof result === "string" ? result : ""; + }, + }; + } catch { + return null; + } +} diff --git a/packages/cli/src/ui/statusline/sanitize.ts b/packages/cli/src/ui/statusline/sanitize.ts new file mode 100644 index 00000000..3beb5f7b --- /dev/null +++ b/packages/cli/src/ui/statusline/sanitize.ts @@ -0,0 +1,21 @@ +export const STATUS_SEGMENT_MAX_LENGTH = 40; + +const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g; + +export function sanitizeStatusText(value: unknown, maxLength: number = STATUS_SEGMENT_MAX_LENGTH): string { + if (value === null || value === undefined) { + return ""; + } + const text = typeof value === "string" ? value : String(value); + if (!text) { + return ""; + } + // Take only first non-empty line, strip ANSI escapes, collapse whitespace. + const firstLine = text.split(/\r?\n/).find((line) => line.trim().length > 0) ?? ""; + const stripped = firstLine.replace(ANSI_PATTERN, ""); + const collapsed = stripped.replace(/\s+/g, " ").trim(); + if (collapsed.length <= maxLength) { + return collapsed; + } + return collapsed.slice(0, Math.max(1, maxLength - 1)) + "…"; +} diff --git a/packages/cli/src/ui/statusline/types.ts b/packages/cli/src/ui/statusline/types.ts new file mode 100644 index 00000000..5639138c --- /dev/null +++ b/packages/cli/src/ui/statusline/types.ts @@ -0,0 +1,39 @@ +import type { StatusLineProviderConfig } from "@vegamo/deepcode-core"; + +export type StatusSegment = { + id: string; + text: string; + color?: string; +}; + +export type SessionInfo = { + activeSessionId: string | null; + messageCount: number; + requestCount: number; + totalTokens: number; + activeTokens: number; + maxContextTokens: number; + model: string; + thinkingEnabled: boolean; + reasoningEffort: string; + toolUsage: Record; +}; + +export type StatusProviderContext = { + projectRoot: string; + signal: AbortSignal; + getSessionInfo?: () => SessionInfo | null; +}; + +export type StatusProvider = { + id: string; + color?: string; + maxLength?: number; + fetch: (ctx: StatusProviderContext) => Promise; + dispose?: () => void; +}; + +export type StatusProviderFactory = ( + config: StatusLineProviderConfig, + projectRoot: string +) => Promise; diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index fe1f81cf..e3cf54a2 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -32,6 +32,8 @@ import { renderRawModeMessages, } from "../utils"; import { resolveCurrentSettings, writeModelConfigSelection } from "@vegamo/deepcode-core"; +import { useStatusLine } from "../hooks"; +import type { SessionInfo } from "../statusline"; import { isCollapsedThinking } from "../core/thinking-state"; import { ANSI_CLEAR_SCREEN } from "../constants"; import type { @@ -45,6 +47,7 @@ import type { UserPromptContent, } from "@vegamo/deepcode-core"; import { SessionManager } from "@vegamo/deepcode-core"; +import { getCompactPromptTokenThreshold } from "@vegamo/deepcode-core"; type View = "chat" | "session-list" | "undo" | "mcp-status"; @@ -637,6 +640,61 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl const screenWidth = useMemo(() => columns ?? stdout?.columns ?? 80, [columns, stdout]); const screenHeight = useMemo(() => rows ?? stdout?.rows ?? 24, [rows, stdout]); + const getSessionInfo = useCallback((): SessionInfo | null => { + const activeSessionId = sessionManager.getActiveSessionId(); + const settings = resolveCurrentSettings(projectRoot); + const model = settings.model || ""; + const thinkingEnabled = settings.thinkingEnabled; + const reasoningEffort = settings.reasoningEffort; + const maxContextTokens = getCompactPromptTokenThreshold(model); + if (!activeSessionId) { + return { + activeSessionId: null, + messageCount: 0, + requestCount: 0, + totalTokens: 0, + activeTokens: 0, + maxContextTokens, + model, + thinkingEnabled, + reasoningEffort, + toolUsage: {}, + }; + } + const session = sessionManager.getSession(activeSessionId); + const messages = sessionManager.listSessionMessages(activeSessionId); + const usage = session?.usage; + const totalTokens = + usage && typeof (usage as { total_tokens?: unknown }).total_tokens === "number" + ? ((usage as { total_tokens: number }).total_tokens ?? 0) + : 0; + const requestCount = + usage && typeof (usage as { total_reqs?: unknown }).total_reqs === "number" + ? ((usage as { total_reqs: number }).total_reqs ?? 0) + : 0; + const toolUsage: Record = {}; + for (const msg of messages) { + if (msg.role === "tool" && msg.meta?.function) { + const fn = msg.meta.function as { name?: string }; + if (fn.name) { + toolUsage[fn.name] = (toolUsage[fn.name] || 0) + 1; + } + } + } + return { + activeSessionId, + messageCount: messages.length, + requestCount, + totalTokens, + activeTokens: session?.activeTokens ?? 0, + maxContextTokens, + model, + thinkingEnabled, + reasoningEffort, + toolUsage, + }; + }, [sessionManager, projectRoot]); + const statusLineSegments = useStatusLine(resolvedSettings.statusline, projectRoot, getSessionInfo); const promptHistory = useMemo(() => { return messages .filter((message) => message.role === "user" && typeof message.content === "string") @@ -872,6 +930,8 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl onInterrupt={handleInterrupt} onToggleProcessStdout={handleToggleProcessStdout} placeholder="Type your message..." + statusLineSegments={statusLineSegments} + statusLineSeparator={resolvedSettings.statusline.separator} /> )} diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 8124d7aa..9a24abe0 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -64,6 +64,7 @@ import type { ModelConfigSelection, PermissionScope } from "@vegamo/deepcode-cor import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "../components"; import type { SessionEntry, SkillInfo } from "@vegamo/deepcode-core"; import type { UserToolPermission } from "@vegamo/deepcode-core"; +import type { StatusSegment } from "../statusline"; export type PromptSubmission = { text: string; @@ -93,6 +94,8 @@ type Props = { placeholder?: string; runningProcesses?: SessionEntry["processes"]; promptDraft?: PromptDraft | null; + statusLineSegments?: StatusSegment[]; + statusLineSeparator?: string; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onRawModeChange?: (mode: string) => void; @@ -123,6 +126,8 @@ export const PromptInput = React.memo(function PromptInput({ placeholder, runningProcesses, promptDraft, + statusLineSegments, + statusLineSeparator, onSubmit, onModelConfigChange, onInterrupt, @@ -839,6 +844,18 @@ export const PromptInput = React.memo(function PromptInput({ {footerText} )} + {statusLineSegments && statusLineSegments.length > 0 && ( + + {statusLineSegments.map((segment, index) => ( + + {index > 0 && {statusLineSeparator ?? " · "}} + + {segment.text} + + + ))} + + )} ); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8ad813a7..832d2444 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,9 @@ export type { PermissionDefaultMode, McpServerConfig, ReasoningEffort, + StatusLineSettings, + ResolvedStatusLineSettings, + StatusLineProviderConfig, } from "./settings"; // Session diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index c91f0306..f7c9f51f 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -45,6 +45,39 @@ export type PermissionSettings = { export type EnabledSkillsSettings = Record; +export type StatusLineProviderConfig = + | { + type: "command"; + id?: string; + command: string; + cwd?: string; + timeoutMs?: number; + color?: string; + maxLength?: number; + } + | { + type: "module"; + id?: string; + path: string; + timeoutMs?: number; + color?: string; + maxLength?: number; + }; + +export type StatusLineSettings = { + enabled?: boolean; + refreshMs?: number; + separator?: string; + providers?: StatusLineProviderConfig[]; +}; + +export type ResolvedStatusLineSettings = { + enabled: boolean; + refreshMs: number; + separator: string; + providers: StatusLineProviderConfig[]; +}; + export type DeepcodingSettings = { env?: DeepcodingEnv; model?: string; @@ -58,6 +91,7 @@ export type DeepcodingSettings = { mcpServers?: Record; permissions?: PermissionSettings; enabledSkills?: EnabledSkillsSettings; + statusline?: StatusLineSettings; }; export type ResolvedDeepcodingSettings = { @@ -75,6 +109,7 @@ export type ResolvedDeepcodingSettings = { mcpServers?: Record; permissions: Required; enabledSkills: EnabledSkillsSettings; + statusline: ResolvedStatusLineSettings; }; export type ModelConfigSelection = { @@ -216,6 +251,116 @@ function mergeEnabledSkills( }; } +const DEFAULT_STATUSLINE_REFRESH_MS = 2000; +const MIN_STATUSLINE_REFRESH_MS = 500; +const DEFAULT_STATUSLINE_SEPARATOR = " · "; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeStatusLineProvider(value: unknown): StatusLineProviderConfig | null { + if (!isPlainObject(value)) { + return null; + } + const type = value["type"]; + const idRaw = trimString(value["id"]); + const id = idRaw || undefined; + const timeoutRaw = value["timeoutMs"]; + const timeoutMs = + typeof timeoutRaw === "number" && Number.isFinite(timeoutRaw) && timeoutRaw > 0 + ? Math.floor(timeoutRaw) + : undefined; + const colorRaw = trimString(value["color"]); + const color = colorRaw || undefined; + const maxLengthRaw = value["maxLength"]; + const maxLength = + typeof maxLengthRaw === "number" && Number.isFinite(maxLengthRaw) && maxLengthRaw > 0 + ? Math.floor(maxLengthRaw) + : undefined; + + if (type === "command") { + const command = trimString(value["command"]); + if (!command) { + return null; + } + const cwdRaw = trimString(value["cwd"]); + return { + type: "command", + id, + command, + cwd: cwdRaw || undefined, + timeoutMs, + color, + maxLength, + }; + } + if (type === "module") { + const modulePath = trimString(value["path"]); + if (!modulePath) { + return null; + } + return { + type: "module", + id, + path: modulePath, + timeoutMs, + color, + maxLength, + }; + } + return null; +} + +function normalizeStatusLine(value: unknown): StatusLineSettings | null { + if (!isPlainObject(value)) { + return null; + } + const result: StatusLineSettings = {}; + const enabled = parseBoolean(value["enabled"]); + if (enabled !== undefined) { + result.enabled = enabled; + } + const refreshRaw = value["refreshMs"]; + if (typeof refreshRaw === "number" && Number.isFinite(refreshRaw) && refreshRaw >= MIN_STATUSLINE_REFRESH_MS) { + result.refreshMs = Math.floor(refreshRaw); + } + const separator = value["separator"]; + if (typeof separator === "string") { + result.separator = separator; + } + const providers = value["providers"]; + if (Array.isArray(providers)) { + const normalized: StatusLineProviderConfig[] = []; + for (const entry of providers) { + const provider = normalizeStatusLineProvider(entry); + if (provider) { + normalized.push(provider); + } + } + result.providers = normalized; + } + return result; +} + +function mergeStatusLine( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined +): ResolvedStatusLineSettings { + const userConfig = normalizeStatusLine(userSettings?.statusline) ?? {}; + const projectConfig = normalizeStatusLine(projectSettings?.statusline) ?? {}; + const providers = [...(userConfig.providers ?? []), ...(projectConfig.providers ?? [])]; + const enabled = projectConfig.enabled ?? userConfig.enabled ?? providers.length > 0; + const refreshMs = projectConfig.refreshMs ?? userConfig.refreshMs ?? DEFAULT_STATUSLINE_REFRESH_MS; + const separator = projectConfig.separator ?? userConfig.separator ?? DEFAULT_STATUSLINE_SEPARATOR; + return { + enabled, + refreshMs, + separator, + providers, + }; +} + function normalizeEnv(env: DeepcodingSettings["env"]): Record { const result: Record = {}; if (!env) { @@ -393,6 +538,7 @@ export function resolveSettingsSources( mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), permissions: mergePermissions(userSettings, projectSettings), enabledSkills: mergeEnabledSkills(userSettings, projectSettings), + statusline: mergeStatusLine(userSettings, projectSettings), }; } From 01b68053838ef43f6ed1a4d1374865676941f247 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 23 Jun 2026 14:42:59 +0800 Subject: [PATCH 02/43] =?UTF-8?q?chore(vscode-ide-companion):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20package.json=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 preview 字段,标记为预览版本 - 修正 repository URL 结构,添加目录字段 - 扩展 categories,新增 Chat 分类 - 格式化部分字段列表,使结构更清晰 --- packages/vscode-ide-companion/package.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index fd4da3ac..6369f37b 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -7,15 +7,18 @@ "license": "MIT", "type": "commonjs", "main": "./out/extension.js", + "preview": true, "repository": { "type": "git", - "url": "git+https://github.com/lessweb/deepcode-cli.git" + "url": "https://github.com/lessweb/deepcode-cli.git", + "directory": "packages/vscode-ide-companion" }, "engines": { "vscode": "^1.85.0" }, "categories": [ - "AI" + "AI", + "Chat" ], "keywords": [ "deep-code", From ad11d9af15bb7114d32c4da02c78394adb5a60a7 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 23 Jun 2026 16:08:24 +0800 Subject: [PATCH 03/43] =?UTF-8?q?feat(cli):=20=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20--resume=20=E5=8F=82=E6=95=B0=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 App 组件中新增 resumeSessionId 属性以支持恢复会话功能 - 在 AppContainer 中传递 resumeSessionId 以贯穿组件树 - 在 cli.tsx 中解析并传递 --resume 参数,支持无 ID 列出会话选择 - 新增构建退出摘要时显示 resume 会话提示的逻辑 - 添加对应单元测试覆盖 resumeSessionId 解析及退出摘要展示 - 抽离 cli 参数解析函数,支持提取初始提示和恢复会话 ID - 修复退出摘要文本,添加 resume 使用提示,提升用户体验 --- packages/cli/src/cli-args.ts | 31 +++++++++ packages/cli/src/cli.tsx | 12 ++-- packages/cli/src/tests/cli-args.test.ts | 76 +++++++++++++++++++++ packages/cli/src/tests/exit-summary.test.ts | 38 +++++++++++ packages/cli/src/ui/exit-summary.ts | 9 ++- packages/cli/src/ui/views/App.tsx | 21 +++++- packages/cli/src/ui/views/AppContainer.tsx | 10 ++- 7 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/cli-args.ts create mode 100644 packages/cli/src/tests/cli-args.test.ts diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts new file mode 100644 index 00000000..5ae0325e --- /dev/null +++ b/packages/cli/src/cli-args.ts @@ -0,0 +1,31 @@ +/** + * CLI argument parsing helpers. + * Extracted from cli.tsx for testability. + */ + +export function extractInitialPrompt(args: string[]): string | undefined { + const promptIndex = args.findIndex((arg) => arg === "-p" || arg === "--prompt"); + if (promptIndex !== -1 && promptIndex + 1 < args.length) { + return args[promptIndex + 1]; + } + return undefined; +} + +/** + * Extract the --resume flag value. + * + * Returns: + * - `undefined` — `--resume` was not used + * - `true` — `--resume` was used without a session ID (show session picker) + * - `string` — `--resume ` was used (resume specific session) + */ +export function extractResumeSessionId(args: string[]): string | true | undefined { + const idx = args.findIndex((arg) => arg === "--resume"); + if (idx === -1) { + return undefined; + } + if (idx + 1 < args.length && !args[idx + 1].startsWith("-")) { + return args[idx + 1]; + } + return true; +} diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index c595916b..4812f9eb 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -3,6 +3,7 @@ import { render } from "ink"; import { setShellIfWindows } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; import { AppContainer } from "./ui"; +import { extractInitialPrompt, extractResumeSessionId } from "./cli-args"; const args = process.argv.slice(2); const packageInfo = readPackageInfo(); @@ -21,6 +22,7 @@ if (args.includes("--help") || args.includes("-h")) { " deepcode Launch the interactive TUI in the current directory", " deepcode -p Launch with a pre-filled prompt", " deepcode --prompt Same as -p", + " deepcode --resume [sessionId] Resume a specific session by its ID. Use without an ID to show session picker", " deepcode --version Print the version", " deepcode --help Show this help", "", @@ -58,15 +60,8 @@ if (args.includes("--help") || args.includes("-h")) { process.exit(0); } -function extractInitialPrompt(args: string[]): string | undefined { - const promptIndex = args.findIndex((arg) => arg === "-p" || arg === "--prompt"); - if (promptIndex !== -1 && promptIndex + 1 < args.length) { - return args[promptIndex + 1]; - } - return undefined; -} - let initialPrompt = extractInitialPrompt(args); +const resumeSessionId = extractResumeSessionId(args); const projectRoot = process.cwd(); configureWindowsShell(); @@ -94,6 +89,7 @@ async function main(): Promise { projectRoot={projectRoot} version={packageInfo.version} initialPrompt={appInitialPrompt} + resumeSessionId={resumeSessionId} onRestart={() => restartRef.current?.()} />, { exitOnCtrlC: false } diff --git a/packages/cli/src/tests/cli-args.test.ts b/packages/cli/src/tests/cli-args.test.ts new file mode 100644 index 00000000..e42a4b00 --- /dev/null +++ b/packages/cli/src/tests/cli-args.test.ts @@ -0,0 +1,76 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { extractInitialPrompt, extractResumeSessionId } from "../cli-args"; + +// ── extractInitialPrompt ───────────────────────────────────────────────────── + +test("extractInitialPrompt returns prompt after -p", () => { + assert.equal(extractInitialPrompt(["-p", "hello world"]), "hello world"); +}); + +test("extractInitialPrompt returns prompt after --prompt", () => { + assert.equal(extractInitialPrompt(["--prompt", "hello world"]), "hello world"); +}); + +test("extractInitialPrompt returns undefined when -p is not present", () => { + assert.equal(extractInitialPrompt(["--version"]), undefined); +}); + +test("extractInitialPrompt returns undefined when -p has no value", () => { + assert.equal(extractInitialPrompt(["-p"]), undefined); +}); + +test("extractInitialPrompt returns undefined for empty args", () => { + assert.equal(extractInitialPrompt([]), undefined); +}); + +test("extractInitialPrompt ignores -p in non-flag position", () => { + assert.equal(extractInitialPrompt(["--resume", "-p", "hello"]), "hello"); +}); + +// ── extractResumeSessionId ─────────────────────────────────────────────────── + +test("extractResumeSessionId returns session ID after --resume", () => { + assert.equal( + extractResumeSessionId(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]), + "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6" + ); +}); + +test("extractResumeSessionId returns true when --resume has no value (show picker)", () => { + assert.equal(extractResumeSessionId(["--resume"]), true); +}); + +test("extractResumeSessionId returns true when --resume is followed by another flag", () => { + assert.equal(extractResumeSessionId(["--resume", "--force"]), true); +}); + +test("extractResumeSessionId returns undefined when --resume is not present", () => { + assert.equal(extractResumeSessionId(["--version"]), undefined); +}); + +test("extractResumeSessionId returns undefined for empty args", () => { + assert.equal(extractResumeSessionId([]), undefined); +}); + +test("extractResumeSessionId works with other flags after sessionId", () => { + assert.equal(extractResumeSessionId(["--resume", "abc-123", "--force"]), "abc-123"); +}); + +test("extractResumeSessionId does not confuse --resume with other args", () => { + assert.equal(extractResumeSessionId(["-p", "test"]), undefined); +}); + +// ── combined usage ─────────────────────────────────────────────────────────── + +test("extractInitialPrompt and extractResumeSessionId work independently", () => { + const args = ["--resume", "session-123", "-p", "hello"]; + assert.equal(extractResumeSessionId(args), "session-123"); + assert.equal(extractInitialPrompt(args), "hello"); +}); + +test("extractResumeSessionId with --resume and -p but no sessionId", () => { + const args = ["--resume", "-p", "hello"]; + assert.equal(extractResumeSessionId(args), true); + assert.equal(extractInitialPrompt(args), "hello"); +}); diff --git a/packages/cli/src/tests/exit-summary.test.ts b/packages/cli/src/tests/exit-summary.test.ts index e0d481db..d768c165 100644 --- a/packages/cli/src/tests/exit-summary.test.ts +++ b/packages/cli/src/tests/exit-summary.test.ts @@ -90,6 +90,44 @@ test("buildExitSummaryText does not derive usage rows from legacy aggregate usag assert.doesNotMatch(summary, /11,966/); }); +test("buildExitSummaryText shows resume hint when sessionId is provided", () => { + const sessionId = "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"; + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession(null), + sessionId, + }) + ); + + assert.match(summary, /Goodbye!/); + assert.match(summary, /deepcode --resume 0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6/); + assert.match(summary, /To continue this session/); +}); + +test("buildExitSummaryText does not show resume hint when sessionId is omitted", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession(null), + }) + ); + + assert.match(summary, /Goodbye!/); + assert.doesNotMatch(summary, /deepcode --resume/); + assert.doesNotMatch(summary, /To continue this session/); +}); + +test("buildExitSummaryText shows resume hint with null session", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: null, + sessionId: "test-session-id", + }) + ); + + assert.match(summary, /Goodbye!/); + assert.match(summary, /deepcode --resume test-session-id/); +}); + function buildSession(usage: ModelUsage | null, usagePerModel: Record | null = null): SessionEntry { return { id: "session-1", diff --git a/packages/cli/src/ui/exit-summary.ts b/packages/cli/src/ui/exit-summary.ts index 25e09b48..baef723a 100644 --- a/packages/cli/src/ui/exit-summary.ts +++ b/packages/cli/src/ui/exit-summary.ts @@ -4,6 +4,7 @@ import type { ModelUsage, SessionEntry } from "@vegamo/deepcode-core"; type ExitSummaryInput = { session: SessionEntry | null; + sessionId?: string; }; const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g; @@ -67,7 +68,7 @@ function extractUsageFields(usage: ModelUsage | null): UsageFields { } export function buildExitSummaryText(input: ExitSummaryInput): string { - const { session } = input; + const { session, sessionId } = input; const innerWidth = 98; const contentWidth = innerWidth - 4; // "│ " prefix + " │" suffix → 4 chars padding @@ -134,6 +135,12 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { rows.push(""); + if (sessionId) { + const resumeHint = chalk.dim(`To continue this session, run deepcode --resume ${sessionId}`); + rows.push(resumeHint); + rows.push(""); + } + const border = borderColor("─".repeat(innerWidth)); const top = `${borderColor("╭")}${border}${borderColor("╮")}`; const bottom = `${borderColor("╰")}${border}${borderColor("╯")}`; diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index fe1f81cf..afcaa26c 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -53,6 +53,7 @@ const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", type AppProps = { projectRoot: string; initialPrompt?: string; + resumeSessionId?: string | true; onRestart?: () => void; }; @@ -89,12 +90,13 @@ const StatusLine = React.memo(function StatusLine({ ); }); -function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement { +function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); const { columns, rows } = useWindowSize(); const { mode, setMode } = useRawModeContext(); const initialPromptSubmittedRef = useRef(false); + const resumeSessionIdRef = useRef(false); const processStdoutRef = useRef>(new Map()); const rawModeRef = useRef(mode); const writeRef = useRef(write); @@ -288,7 +290,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl setTimeout(() => { const activeSessionId = sessionManager.getActiveSessionId(); const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; - const summary = buildExitSummaryText({ session }); + const summary = buildExitSummaryText({ session, sessionId: activeSessionId ?? undefined }); process.stdout.write("\n"); process.stdout.write(chalk.rgb(34, 154, 195)("> /exit ")); process.stdout.write("\n\n"); @@ -506,6 +508,21 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl [sessionManager, resetStaticView, pendingPermissionReply, refreshSkills] ); + useEffect(() => { + if (resumeSessionIdRef.current || !resumeSessionId) { + return; + } + + resumeSessionIdRef.current = true; + if (resumeSessionId === true) { + // No session ID — show the session picker (same as /resume) + refreshSessionsList(); + navigateToSubView("session-list"); + } else { + handleSelectSession(resumeSessionId); + } + }, [handleSelectSession, navigateToSubView, refreshSessionsList, resumeSessionId]); + const handleDeleteSession = useCallback( async (id: string): Promise => { const isActiveSession = sessionManager.getActiveSessionId() === id; diff --git a/packages/cli/src/ui/views/AppContainer.tsx b/packages/cli/src/ui/views/AppContainer.tsx index d5f6363a..555588f1 100644 --- a/packages/cli/src/ui/views/AppContainer.tsx +++ b/packages/cli/src/ui/views/AppContainer.tsx @@ -7,12 +7,18 @@ const AppContainer: React.FC<{ projectRoot: string; version: string; initialPrompt: string | undefined; + resumeSessionId: string | true | undefined; onRestart: () => void; -}> = ({ version, projectRoot, initialPrompt, onRestart }) => { +}> = ({ version, projectRoot, initialPrompt, resumeSessionId, onRestart }) => { return ( - + ); From 361f2b171fe72efd4d588b5730105b22626a1394 Mon Sep 17 00:00:00 2001 From: hcyang Date: Tue, 23 Jun 2026 16:31:37 +0800 Subject: [PATCH 04/43] =?UTF-8?q?fix(cli):=20=E9=AA=8C=E8=AF=81=20--resume?= =?UTF-8?q?=20=E5=8F=82=E6=95=B0=E4=B8=AD=E7=9A=84=E4=BC=9A=E8=AF=9DID?= =?UTF-8?q?=E6=9C=89=E6=95=88=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在启动 TUI 之前校验传入的 resumeSessionId 是否在本地会话索引中存在 - 读取用户主目录下的 sessions-index.json 文件进行会话ID验证 - 未找到匹配会话时输出错误信息并退出进程 - 在 App 组件中移除对会话ID重复验证的注释补充说明 - 确保 resumeSessionId 已经校验通过后才调用 handleSelectSession --- packages/cli/src/cli.tsx | 22 +++++++++++++++++++++- packages/cli/src/ui/views/App.tsx | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 4812f9eb..80513f78 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,6 +1,9 @@ import React from "react"; import { render } from "ink"; -import { setShellIfWindows } from "@vegamo/deepcode-core"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; import { AppContainer } from "./ui"; import { extractInitialPrompt, extractResumeSessionId } from "./cli-args"; @@ -65,6 +68,23 @@ const resumeSessionId = extractResumeSessionId(args); const projectRoot = process.cwd(); configureWindowsShell(); +// Validate --resume before entering TUI +if (typeof resumeSessionId === "string") { + const projectCode = getProjectCode(projectRoot); + const indexPath = join(homedir(), ".deepcode", "projects", projectCode, "sessions-index.json"); + try { + const index = JSON.parse(readFileSync(indexPath, "utf-8")); + const found = Array.isArray(index?.entries) && index.entries.some((e: { id: string }) => e.id === resumeSessionId); + if (!found) { + process.stderr.write(`No saved session found with ID "${resumeSessionId}".\n`); + process.exit(1); + } + } catch { + process.stderr.write(`No saved session found with ID "${resumeSessionId}".\n`); + process.exit(1); + } +} + if (!process.stdin.isTTY) { process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); process.exit(1); diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index afcaa26c..4175b49d 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -519,6 +519,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp refreshSessionsList(); navigateToSubView("session-list"); } else { + // Session ID already validated in cli.tsx — guaranteed to exist handleSelectSession(resumeSessionId); } }, [handleSelectSession, navigateToSubView, refreshSessionsList, resumeSessionId]); From dc068a6935b3e14d01970a9775e6b7207397ded5 Mon Sep 17 00:00:00 2001 From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:49:17 +0800 Subject: [PATCH 05/43] fix: prevent duplicate statusline when user/project settings are the same file When the CLI is launched from ~ (home directory), user-level and project-level settings.json resolve to the same file. This caused resolveSettingsSources to merge the same content twice, resulting in duplicate statusline segments and React non-unique key warnings. Fix: detect same-file early in resolveCurrentSettings and pass null for project settings when paths are identical. --- packages/core/src/settings.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index f7c9f51f..019b7f38 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -654,9 +654,12 @@ export function writeModelConfigSelection( } export function resolveCurrentSettings(projectRoot: string = process.cwd()): ResolvedDeepcodingSettings { + const userPath = path.resolve(getUserSettingsPath()); + const projectPath = path.resolve(getProjectSettingsPath(projectRoot)); + const sameFile = userPath === projectPath; return resolveSettingsSources( readSettings(), - readProjectSettings(projectRoot), + sameFile ? null : readProjectSettings(projectRoot), { model: DEFAULT_MODEL, baseURL: DEFAULT_BASE_URL, From a2c74df41679301089c089890f24d0d05586460c Mon Sep 17 00:00:00 2001 From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:05:24 +0800 Subject: [PATCH 06/43] fix: statusline provider id dedup, newLine support, and CI type errors - Project-level providers override user-level by id instead of appending - Add newLine option to break statusline into multiple rows - Remove external deepcode-core from esbuild (bundle inline) - Fix permissions.test.ts readonly array type errors --- packages/cli/src/tests/statusline.test.ts | 29 ++++++++++++++- .../cli/src/ui/statusline/command-provider.ts | 1 + packages/cli/src/ui/statusline/manager.ts | 22 ++++++++++-- packages/cli/src/ui/statusline/types.ts | 2 ++ packages/cli/src/ui/views/PromptInput.tsx | 36 ++++++++++++++----- packages/core/src/settings.ts | 10 +++++- packages/core/src/tests/permissions.test.ts | 25 ++++++------- scripts/esbuild.config.js | 1 - 8 files changed, 100 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/tests/statusline.test.ts b/packages/cli/src/tests/statusline.test.ts index fffc15a1..4417cca1 100644 --- a/packages/cli/src/tests/statusline.test.ts +++ b/packages/cli/src/tests/statusline.test.ts @@ -7,7 +7,7 @@ import { sanitizeStatusText, STATUS_SEGMENT_MAX_LENGTH } from "../ui/statusline/ import { validateModulePath, loadModuleProvider } from "../ui/statusline/module-provider"; import { createCommandStatusProvider } from "../ui/statusline/command-provider"; import { StatusLineManager } from "../ui/statusline/manager"; -import { resolveSettings } from "@vegamo/deepcode-core"; +import { resolveSettings, resolveSettingsSources } from "@vegamo/deepcode-core"; import type { ResolvedStatusLineSettings } from "@vegamo/deepcode-core"; test("sanitizeStatusText returns empty for null/undefined/empty", () => { @@ -171,6 +171,33 @@ test("loadModuleProvider succeeds for a well-formed module", async () => { } }); +test("resolveSettingsSources lets project-level providers override user-level by id", () => { + const resolved = resolveSettingsSources( + { + statusline: { + enabled: true, + providers: [ + { type: "command", id: "model", command: "echo user-model" }, + { type: "command", id: "branch", command: "echo user-branch" }, + ], + }, + }, + { + statusline: { + providers: [ + { type: "command", id: "model", command: "echo project-model" }, + { type: "command", id: "cwd", command: "echo project-cwd" }, + ], + }, + }, + { model: "default-model", baseURL: "https://default.example.com" } + ); + const ids = resolved.statusline.providers.map((p) => p.id); + assert.deepEqual(ids, ["branch", "model", "cwd"]); + const modelProvider = resolved.statusline.providers.find((p) => p.id === "model"); + assert.equal(modelProvider?.type === "command" && modelProvider.command, "echo project-model"); +}); + test("StatusLineManager emits segments after fetch and stops cleanly", async () => { const config: ResolvedStatusLineSettings = { enabled: true, diff --git a/packages/cli/src/ui/statusline/command-provider.ts b/packages/cli/src/ui/statusline/command-provider.ts index fb6327d0..c89545a4 100644 --- a/packages/cli/src/ui/statusline/command-provider.ts +++ b/packages/cli/src/ui/statusline/command-provider.ts @@ -32,6 +32,7 @@ export function createCommandStatusProvider( return { id, color: config.color, + newLine: config.newLine, maxLength: config.maxLength, fetch: ({ signal }: StatusProviderContext) => new Promise((resolve) => { diff --git a/packages/cli/src/ui/statusline/manager.ts b/packages/cli/src/ui/statusline/manager.ts index 4ebd8888..9f4015ff 100644 --- a/packages/cli/src/ui/statusline/manager.ts +++ b/packages/cli/src/ui/statusline/manager.ts @@ -11,7 +11,12 @@ function segmentsEqual(a: StatusSegment[], b: StatusSegment[]): boolean { return false; } for (let i = 0; i < a.length; i++) { - if (a[i]?.id !== b[i]?.id || a[i]?.text !== b[i]?.text || a[i]?.color !== b[i]?.color) { + if ( + a[i]?.id !== b[i]?.id || + a[i]?.text !== b[i]?.text || + a[i]?.color !== b[i]?.color || + a[i]?.newLine !== b[i]?.newLine + ) { return false; } } @@ -130,7 +135,17 @@ export class StatusLineManager { if (!resolvedPath) { return null; } - return loadModuleProvider(resolvedPath, config.color, providerId, config.timeoutMs, config.maxLength); + const provider = await loadModuleProvider( + resolvedPath, + config.color, + providerId, + config.timeoutMs, + config.maxLength + ); + if (provider && config.newLine) { + provider.newLine = true; + } + return provider; } return null; } @@ -156,6 +171,9 @@ export class StatusLineManager { if (provider.color) { segment.color = provider.color; } + if (provider.newLine) { + segment.newLine = true; + } return segment; } catch { return null; diff --git a/packages/cli/src/ui/statusline/types.ts b/packages/cli/src/ui/statusline/types.ts index 5639138c..6f687e61 100644 --- a/packages/cli/src/ui/statusline/types.ts +++ b/packages/cli/src/ui/statusline/types.ts @@ -4,6 +4,7 @@ export type StatusSegment = { id: string; text: string; color?: string; + newLine?: boolean; }; export type SessionInfo = { @@ -29,6 +30,7 @@ export type StatusProvider = { id: string; color?: string; maxLength?: number; + newLine?: boolean; fetch: (ctx: StatusProviderContext) => Promise; dispose?: () => void; }; diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 9a24abe0..2bf720b1 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -845,15 +845,33 @@ export const PromptInput = React.memo(function PromptInput({ )} {statusLineSegments && statusLineSegments.length > 0 && ( - - {statusLineSegments.map((segment, index) => ( - - {index > 0 && {statusLineSeparator ?? " · "}} - - {segment.text} - - - ))} + + {(() => { + const lines: StatusSegment[][] = []; + let currentLine: StatusSegment[] = []; + for (const segment of statusLineSegments) { + if (segment.newLine && currentLine.length > 0) { + lines.push(currentLine); + currentLine = []; + } + currentLine.push(segment); + } + if (currentLine.length > 0) { + lines.push(currentLine); + } + return lines.map((line, lineIndex) => ( + + {line.map((segment, index) => ( + + {index > 0 && {statusLineSeparator ?? " · "}} + + {segment.text} + + + ))} + + )); + })()} )} diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 019b7f38..5dab3b5a 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -53,6 +53,7 @@ export type StatusLineProviderConfig = cwd?: string; timeoutMs?: number; color?: string; + newLine?: boolean; maxLength?: number; } | { @@ -61,6 +62,7 @@ export type StatusLineProviderConfig = path: string; timeoutMs?: number; color?: string; + newLine?: boolean; maxLength?: number; }; @@ -278,6 +280,7 @@ function normalizeStatusLineProvider(value: unknown): StatusLineProviderConfig | typeof maxLengthRaw === "number" && Number.isFinite(maxLengthRaw) && maxLengthRaw > 0 ? Math.floor(maxLengthRaw) : undefined; + const newLine = value["newLine"] === true ? true : undefined; if (type === "command") { const command = trimString(value["command"]); @@ -292,6 +295,7 @@ function normalizeStatusLineProvider(value: unknown): StatusLineProviderConfig | cwd: cwdRaw || undefined, timeoutMs, color, + newLine, maxLength, }; } @@ -306,6 +310,7 @@ function normalizeStatusLineProvider(value: unknown): StatusLineProviderConfig | path: modulePath, timeoutMs, color, + newLine, maxLength, }; } @@ -349,7 +354,10 @@ function mergeStatusLine( ): ResolvedStatusLineSettings { const userConfig = normalizeStatusLine(userSettings?.statusline) ?? {}; const projectConfig = normalizeStatusLine(projectSettings?.statusline) ?? {}; - const providers = [...(userConfig.providers ?? []), ...(projectConfig.providers ?? [])]; + const userProviders = userConfig.providers ?? []; + const projectProviders = projectConfig.providers ?? []; + const projectIds = new Set(projectProviders.map((p) => p.id)); + const providers = [...userProviders.filter((p) => !projectIds.has(p.id)), ...projectProviders]; const enabled = projectConfig.enabled ?? userConfig.enabled ?? providers.length > 0; const refreshMs = projectConfig.refreshMs ?? userConfig.refreshMs ?? DEFAULT_STATUSLINE_REFRESH_MS; const separator = projectConfig.separator ?? userConfig.separator ?? DEFAULT_STATUSLINE_SEPARATOR; diff --git a/packages/core/src/tests/permissions.test.ts b/packages/core/src/tests/permissions.test.ts index 5e8bf1e8..bc7393c7 100644 --- a/packages/core/src/tests/permissions.test.ts +++ b/packages/core/src/tests/permissions.test.ts @@ -11,6 +11,7 @@ import { isPathInAnyDirectory, parseBashSideEffects, } from "../common/permissions"; +import type { PermissionScope } from "../settings"; const tempDirs: string[] = []; @@ -52,10 +53,10 @@ test("computeToolCallPermissions maps tool calls to permission requests", () => sessionId: "session-1", projectRoot, settings: { - allow: [], - deny: [], - ask: ["write-out-cwd", "network"], - defaultMode: "allowAll", + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: ["write-out-cwd", "network"] as PermissionScope[], + defaultMode: "allowAll" as const, }, resolveSnippetPath: () => path.join(projectRoot, "src", "file.ts"), toolCalls: [ @@ -100,10 +101,10 @@ test("computeToolCallPermissions only asks for scopes not already allowed", () = sessionId: "session-1", projectRoot, settings: { - allow: ["read-in-cwd"], - deny: [], - ask: [], - defaultMode: "askAll", + allow: ["read-in-cwd"] as PermissionScope[], + deny: [] as PermissionScope[], + ask: [] as PermissionScope[], + defaultMode: "askAll" as const, }, toolCalls: [ { @@ -138,10 +139,10 @@ test("computeToolCallPermissions allows read tool calls under skill scan paths", projectRoot, readPermissionExemptPaths: [skillRoot], settings: { - allow: [], - deny: [], - ask: [], - defaultMode: "askAll", + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: [] as PermissionScope[], + defaultMode: "askAll" as const, }, toolCalls: [ { diff --git a/scripts/esbuild.config.js b/scripts/esbuild.config.js index 36c174dc..bf814a32 100644 --- a/scripts/esbuild.config.js +++ b/scripts/esbuild.config.js @@ -20,7 +20,6 @@ await build({ jsx: "automatic", jsxImportSource: "react", packages: "external", - external: ["@vegamo/deepcode-core"], logOverride: { "empty-import-meta": "silent", }, From afc5ca82921c4abcba5284acaa0574676dbb7379 Mon Sep 17 00:00:00 2001 From: dengmik-commits <270912164+dengmik-commits@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:18:26 +0800 Subject: [PATCH 07/43] fix: resolve permissions.test.ts type errors from upstream merge Add PermissionSettings import and use explicit type annotations for settings objects with empty arrays (as const produces readonly never[] which is incompatible with Required). --- packages/core/src/tests/permissions.test.ts | 52 ++++++++++----------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/core/src/tests/permissions.test.ts b/packages/core/src/tests/permissions.test.ts index 26baecad..fd3b676a 100644 --- a/packages/core/src/tests/permissions.test.ts +++ b/packages/core/src/tests/permissions.test.ts @@ -12,7 +12,7 @@ import { isPathInAnyDirectory, parseBashSideEffects, } from "../common/permissions"; -import type { PermissionScope } from "../settings"; +import type { PermissionScope, PermissionSettings } from "../settings"; const tempDirs: string[] = []; @@ -33,11 +33,11 @@ test("parseBashSideEffects accepts valid scopes and normalizes unsafe values to }); test("evaluatePermissionScopes applies deny, ask, allow, and default mode precedence", () => { - const settings = { - allow: ["read-in-cwd" as const], - deny: ["write-out-cwd" as const], - ask: ["network" as const], - defaultMode: "askAll" as const, + const settings: Required = { + allow: ["read-in-cwd"] as PermissionScope[], + deny: ["write-out-cwd"] as PermissionScope[], + ask: ["network"] as PermissionScope[], + defaultMode: "askAll", }; assert.equal(evaluatePermissionScopes(["write-out-cwd"], settings), "deny"); @@ -49,41 +49,41 @@ test("evaluatePermissionScopes applies deny, ask, allow, and default mode preced }); test("evaluatePermissionScopes allows unknown when defaultMode is allowAll", () => { - const allowAllSettings = { - allow: [] as const, - deny: [] as const, - ask: [] as const, - defaultMode: "allowAll" as const, + const allowAllSettings: Required = { + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: [] as PermissionScope[], + defaultMode: "allowAll", }; assert.equal(evaluatePermissionScopes(["unknown"], allowAllSettings), "allow"); // unknown + other scopes that would otherwise trigger ask should still ask for those scopes - const askNetworkSettings = { - allow: [] as const, - deny: [] as const, - ask: ["network" as const], - defaultMode: "allowAll" as const, + const askNetworkSettings: Required = { + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: ["network"] as PermissionScope[], + defaultMode: "allowAll", }; assert.equal(evaluatePermissionScopes(["unknown", "network"], askNetworkSettings), "ask"); }); test("getPermissionScopesRequiringAsk excludes unknown when defaultMode is allowAll", () => { - const allowAllSettings = { - allow: [] as const, - deny: [] as const, - ask: ["network" as const], - defaultMode: "allowAll" as const, + const allowAllSettings: Required = { + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: ["network"] as PermissionScope[], + defaultMode: "allowAll", }; const result = getPermissionScopesRequiringAsk(["unknown", "network"], allowAllSettings); assert.deepEqual(result, ["network"]); }); test("getPermissionScopesRequiringAsk includes unknown when defaultMode is askAll", () => { - const askAllSettings = { - allow: [] as const, - deny: [] as const, - ask: ["network" as const], - defaultMode: "askAll" as const, + const askAllSettings: Required = { + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: ["network"] as PermissionScope[], + defaultMode: "askAll", }; const result = getPermissionScopesRequiringAsk(["unknown", "network"], askAllSettings); assert.deepEqual(result, ["unknown", "network"]); From a3952e11115236dbd0bd31dee89f954a7c1a3b08 Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 24 Jun 2026 09:24:54 +0800 Subject: [PATCH 08/43] =?UTF-8?q?refactor(cli):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20existsSync=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 node:fs 模块中未使用的 existsSync 导入 - 保持代码整洁,避免冗余依赖 - 优化代码可读性和维护性 --- packages/cli/src/cli.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 80513f78..6ac5372d 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,6 +1,6 @@ import React from "react"; import { render } from "ink"; -import { readFileSync, existsSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; From f4ded9a866c157169ad6fc16b70b700283c46ac6 Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 24 Jun 2026 10:04:33 +0800 Subject: [PATCH 09/43] =?UTF-8?q?feat(cli):=20=E5=A2=9E=E5=8A=A0Git?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=BF=A1=E6=81=AF=E5=92=8C=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入并展示CLI版本及Git提交信息 - 优化退出摘要界面颜色,提升可读性 - 在会话恢复提示中添加高亮命令显示 - 在PackageInfo类型中添加gitCommit字段支持 --- packages/cli/src/cli.tsx | 6 ++++-- packages/cli/src/common/update-check.ts | 1 + packages/cli/src/ui/exit-summary.ts | 7 ++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 6ac5372d..48152d0b 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -7,6 +7,7 @@ import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; import { AppContainer } from "./ui"; import { extractInitialPrompt, extractResumeSessionId } from "./cli-args"; +import { CLI_VERSION, GIT_COMMIT_INFO } from "./generated/git-commit"; const args = process.argv.slice(2); const packageInfo = readPackageInfo(); @@ -151,9 +152,10 @@ function readPackageInfo(): PackageInfo { const pkg = require("../package.json") as { name?: unknown; version?: unknown }; return { name: typeof pkg.name === "string" ? pkg.name : "@vegamo/deepcode-cli", - version: typeof pkg.version === "string" ? pkg.version : "", + version: typeof pkg.version === "string" ? pkg.version : (CLI_VERSION ?? ""), + gitCommit: GIT_COMMIT_INFO ?? "", }; } catch { - return { name: "@vegamo/deepcode-cli", version: "" }; + return { name: "@vegamo/deepcode-cli", version: CLI_VERSION ?? "", gitCommit: GIT_COMMIT_INFO ?? "" }; } } diff --git a/packages/cli/src/common/update-check.ts b/packages/cli/src/common/update-check.ts index 7a4710be..3b82e51a 100644 --- a/packages/cli/src/common/update-check.ts +++ b/packages/cli/src/common/update-check.ts @@ -10,6 +10,7 @@ import { killProcessTree } from "@vegamo/deepcode-core"; export type PackageInfo = { name: string; version: string; + gitCommit?: string; }; type UpdateState = { diff --git a/packages/cli/src/ui/exit-summary.ts b/packages/cli/src/ui/exit-summary.ts index baef723a..1a28ab8f 100644 --- a/packages/cli/src/ui/exit-summary.ts +++ b/packages/cli/src/ui/exit-summary.ts @@ -73,7 +73,7 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const innerWidth = 98; const contentWidth = innerWidth - 4; // "│ " prefix + " │" suffix → 4 chars padding - const borderColor = chalk.hex("#229ac3e6"); + const borderColor = chalk.dim; const titleColor = gradientString("#229ac3e6", "rgb(125 51 247 / 0.7)"); const line = (text: string) => `${borderColor("│")} ${padRight(text, contentWidth)} ${borderColor("│")}`; @@ -114,7 +114,7 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { padLeft("Output Tokens", colOutput) + padLeft("Cached Tokens", colCached); rows.push(chalk.bold(headerRow)); - rows.push(divider); + rows.push(chalk.gray(divider)); for (const { modelName, usage } of usageRows) { const reqsStr = formatNumber(usage.totalReqs).padStart(colReqs); @@ -136,7 +136,8 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { rows.push(""); if (sessionId) { - const resumeHint = chalk.dim(`To continue this session, run deepcode --resume ${sessionId}`); + const resumeHint = + chalk.dim(`To continue this session, run `) + chalk.hex("#229ac3")(`deepcode --resume ${sessionId}`); rows.push(resumeHint); rows.push(""); } From 428fd511655e7f528d160fc07d8009b58cbdf175 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 17:19:09 +0800 Subject: [PATCH 10/43] Refactor DeepCode server package Squash merge headless-server-command-map into main. - Add standalone @vegamo/deepcode-server workspace package - Split server services and HTTP/SSE runtime host - Keep server API docs concise in Chinese and English - Preserve CLI and VSCode companion as separate entrypoints --- docs/server-api.md | 23 + docs/server-api_en.md | 23 + package-lock.json | 19 + packages/server/package.json | 35 ++ packages/server/src/command-map.ts | 71 +++ packages/server/src/index.ts | 2 + packages/server/src/model-options.ts | 27 + packages/server/src/server.ts | 46 ++ packages/server/src/services/auth.ts | 22 + packages/server/src/services/events.ts | 17 + packages/server/src/services/http-server.ts | 132 +++++ packages/server/src/services/images.ts | 135 +++++ packages/server/src/services/lifecycle.ts | 26 + packages/server/src/services/model-config.ts | 70 +++ packages/server/src/services/open-file.ts | 54 ++ packages/server/src/services/permissions.ts | 58 ++ .../server/src/services/prompt-content.ts | 48 ++ packages/server/src/services/request-body.ts | 64 ++ packages/server/src/services/response.ts | 61 ++ packages/server/src/services/routes.ts | 161 +++++ .../server/src/services/runtime-contract.ts | 38 ++ packages/server/src/services/runtime.ts | 548 ++++++++++++++++++ .../server/src/services/server-options.ts | 43 ++ .../src/services/session-serialization.ts | 41 ++ packages/server/src/services/sse.ts | 35 ++ packages/server/src/services/types.ts | 11 + packages/server/tsconfig.json | 21 + scripts/rewrite-esm-imports.js | 30 +- 28 files changed, 1853 insertions(+), 8 deletions(-) create mode 100644 docs/server-api.md create mode 100644 docs/server-api_en.md create mode 100644 packages/server/package.json create mode 100644 packages/server/src/command-map.ts create mode 100644 packages/server/src/index.ts create mode 100644 packages/server/src/model-options.ts create mode 100755 packages/server/src/server.ts create mode 100644 packages/server/src/services/auth.ts create mode 100644 packages/server/src/services/events.ts create mode 100644 packages/server/src/services/http-server.ts create mode 100644 packages/server/src/services/images.ts create mode 100644 packages/server/src/services/lifecycle.ts create mode 100644 packages/server/src/services/model-config.ts create mode 100644 packages/server/src/services/open-file.ts create mode 100644 packages/server/src/services/permissions.ts create mode 100644 packages/server/src/services/prompt-content.ts create mode 100644 packages/server/src/services/request-body.ts create mode 100644 packages/server/src/services/response.ts create mode 100644 packages/server/src/services/routes.ts create mode 100644 packages/server/src/services/runtime-contract.ts create mode 100644 packages/server/src/services/runtime.ts create mode 100644 packages/server/src/services/server-options.ts create mode 100644 packages/server/src/services/session-serialization.ts create mode 100644 packages/server/src/services/sse.ts create mode 100644 packages/server/src/services/types.ts create mode 100644 packages/server/tsconfig.json diff --git a/docs/server-api.md b/docs/server-api.md new file mode 100644 index 00000000..7ec23ef2 --- /dev/null +++ b/docs/server-api.md @@ -0,0 +1,23 @@ +# DeepCode Server API + +[English](./server-api_en.md) · 中文 + +```bash +deepcode-server --port 8787 +``` + +默认开启 token 鉴权。stdout 会打印类似: + +```text +deepcode server listening on http://127.0.0.1:8787 token= +``` + +通过以下任一方式传递: + +- `?token=` +- `x-deepcode-token: ` +- `Authorization: Bearer ` + +## 范围 + +Server 暴露现有 DeepCode runtime / CLI-TUI 已有能力作为 http api。斜杠命令含义参考 [README 的“斜杠命令与按键功能”](../README.md#斜杠命令与按键功能)。 diff --git a/docs/server-api_en.md b/docs/server-api_en.md new file mode 100644 index 00000000..1ddf0fc9 --- /dev/null +++ b/docs/server-api_en.md @@ -0,0 +1,23 @@ +# DeepCode Server API + +English · [中文](./server-api.md) + +```bash +deepcode-server --port 8787 +``` + +Token authentication is enabled by default. stdout prints a line similar to: + +```text +deepcode server listening on http://127.0.0.1:8787 token= +``` + +Pass the token with any of: + +- `?token=` +- `x-deepcode-token: ` +- `Authorization: Bearer ` + +## Scope + +The server exposes existing DeepCode runtime / CLI-TUI capabilities as HTTP APIs. For slash command meanings, see [README “Slash Commands & Keyboard Shortcuts”](../README-en.md#slash-commands--keyboard-shortcuts). diff --git a/package-lock.json b/package-lock.json index 0e4b47f0..2710105a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1965,6 +1965,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@vegamo/deepcode-server": { + "resolved": "packages/server", + "link": true + }, "node_modules/@vscode/vsce": { "version": "3.9.2", "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", @@ -7549,6 +7553,21 @@ "node": ">= 4" } }, + "packages/server": { + "name": "@vegamo/deepcode-server", + "version": "0.1.31", + "license": "MIT", + "dependencies": { + "@vegamo/deepcode-core": "file:../core" + }, + "bin": { + "deepcode-headless-server": "dist/server.js", + "deepcode-server": "dist/server.js" + }, + "engines": { + "node": ">=22" + } + }, "packages/vscode-ide-companion": { "name": "deepcode-vscode", "version": "0.1.22", diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 00000000..3298dddc --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,35 @@ +{ + "name": "@vegamo/deepcode-server", + "version": "0.1.31", + "description": "Deep Code local HTTP/SSE server runtime host", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/lessweb/deepcode-cli.git" + }, + "homepage": "https://deepcode.vegamo.cn", + "bin": { + "deepcode-server": "./dist/server.js", + "deepcode-headless-server": "./dist/server.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/**", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=22" + }, + "scripts": { + "typecheck": "tsc -p ./ --noEmit", + "build": "tsc -p ./ && node ../../scripts/rewrite-esm-imports.js server && node -e \"require('fs').chmodSync('dist/server.js', 0o755)\"", + "prepublishOnly": "npm run build", + "format": "prettier --write ." + }, + "dependencies": { + "@vegamo/deepcode-core": "file:../core" + } +} diff --git a/packages/server/src/command-map.ts b/packages/server/src/command-map.ts new file mode 100644 index 00000000..12e84cda --- /dev/null +++ b/packages/server/src/command-map.ts @@ -0,0 +1,71 @@ +/** + * Server slash-command route metadata. + * + * Summary: + * Maps supported DeepCode runtime slash commands to HTTP route metadata for the + * local server. This module is intentionally independent from the CLI slash + * command table so server behavior does not depend on terminal UI code. + * + * Exports: + * - buildHeadlessCommandRoutes(): HeadlessCommandRoute[] + * - findHeadlessCommandRoute(pathname: string): HeadlessCommandRoute | null + */ +export type HeadlessCommandRoute = { + name: string; + label: string; + description: string; + method: "GET" | "POST"; + path: string; + aliases: string[]; + implemented: boolean; +}; + +type RuntimeCommand = { + name: string; + label: string; + description: string; +}; + +const RUNTIME_COMMANDS: RuntimeCommand[] = [ + { name: "skills", label: "/skills", description: "List available skills" }, + { name: "model", label: "/model", description: "Select model, thinking mode and effort control" }, + { name: "new", label: "/new", description: "Start a fresh conversation" }, + { name: "init", label: "/init", description: "Initialize an AGENTS.md file with instructions for LLM" }, + { name: "resume", label: "/resume", description: "Pick a previous conversation to continue" }, + { name: "continue", label: "/continue", description: "Continue the active conversation or pick one to resume" }, + { name: "undo", label: "/undo", description: "Restore code and/or conversation to a previous point" }, + { name: "mcp", label: "/mcp", description: "Show MCP server status and available tools" }, + { name: "raw", label: "/raw", description: "CLI display mode command; no backend raw display state is exposed" }, + { name: "exit", label: "/exit", description: "Quit Deep Code server" }, +]; + +const READ_ONLY_COMMANDS = new Set(["skills", "resume", "mcp", "model"]); +const IMPLEMENTED_COMMANDS = new Set(["skills", "new", "init", "resume", "continue", "undo", "mcp", "model", "exit"]); + +function commandMethod(command: RuntimeCommand): "GET" | "POST" { + return READ_ONLY_COMMANDS.has(command.name) ? "GET" : "POST"; +} + +function commandAliases(command: RuntimeCommand): string[] { + if (command.name === "model") { + return ["/model"]; + } + return [`/${command.name}`, `/api/${command.name}`]; +} + +export function buildHeadlessCommandRoutes(): HeadlessCommandRoute[] { + return RUNTIME_COMMANDS.map((command) => ({ + name: command.name, + label: command.label, + description: command.description, + method: commandMethod(command), + path: `/${command.name}`, + aliases: commandAliases(command), + implemented: IMPLEMENTED_COMMANDS.has(command.name), + })); +} + +export function findHeadlessCommandRoute(pathname: string): HeadlessCommandRoute | null { + const normalized = pathname.replace(/\/+$/u, "") || "/"; + return buildHeadlessCommandRoutes().find((route) => route.aliases.includes(normalized)) ?? null; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 00000000..df892b0c --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,2 @@ +export { runHeadlessHttp } from "./services/http-server"; +export type { HeadlessOptions } from "./services/http-server"; diff --git a/packages/server/src/model-options.ts b/packages/server/src/model-options.ts new file mode 100644 index 00000000..026dec15 --- /dev/null +++ b/packages/server/src/model-options.ts @@ -0,0 +1,27 @@ +/** + * Server model option constants. + * + * Summary: + * Defines the model and thinking-mode choices exposed by the local HTTP/SSE + * server contract. This module contains data only and has no CLI, UI, Ink, or + * React dependency. + * + * Exports: + * - MODEL_COMMAND_MODELS: readonly model id list exposed by GET /model. + * - MODEL_COMMAND_THINKING_OPTIONS: thinking-mode choices exposed by GET /model. + */ +import type { ReasoningEffort } from "@vegamo/deepcode-core"; + +export type ThinkingModeOption = { + label: string; + thinkingEnabled: boolean; + reasoningEffort?: ReasoningEffort; +}; + +export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; + +export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [ + { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" }, + { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" }, + { label: "No thinking", thinkingEnabled: false }, +]; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts new file mode 100755 index 00000000..3a567e93 --- /dev/null +++ b/packages/server/src/server.ts @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { setShellIfWindows } from "@vegamo/deepcode-core"; +import { runHeadlessHttp } from "./index"; + +const require = createRequire(import.meta.url); + +type PackageInfo = { + version?: unknown; +}; + +const args = process.argv.slice(2); +const projectRoot = process.cwd(); +configureWindowsShell(); + +try { + await runHeadlessHttp({ + args, + projectRoot, + version: readVersion(), + }); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`deepcode server failed: ${message}\n`); + process.exit(1); +} + +function configureWindowsShell(): void { + process.env.NoDefaultCurrentDirectoryInExePath = "1"; + try { + setShellIfWindows(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`deepcode server: ${message}\n`); + process.exit(1); + } +} + +function readVersion(): string { + try { + const pkg = require("../package.json") as PackageInfo; + return typeof pkg.version === "string" ? pkg.version : "unknown"; + } catch { + return "unknown"; + } +} diff --git a/packages/server/src/services/auth.ts b/packages/server/src/services/auth.ts new file mode 100644 index 00000000..30fd0b1b --- /dev/null +++ b/packages/server/src/services/auth.ts @@ -0,0 +1,22 @@ +/** + * Request authorization helpers. + * + * Summary: + * Contains token matching logic for local HTTP requests. This is separated from + * route execution so auth policy can be audited independently. + * + * Exports: + * - isAuthorized(request: IncomingMessage, token: string): boolean + */ +import type { IncomingMessage } from "node:http"; + +export function isAuthorized(request: IncomingMessage, token: string): boolean { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (url.searchParams.get("token") === token) { + return true; + } + if (request.headers["x-deepcode-token"] === token) { + return true; + } + return request.headers.authorization === `Bearer ${token}`; +} diff --git a/packages/server/src/services/events.ts b/packages/server/src/services/events.ts new file mode 100644 index 00000000..fc39918b --- /dev/null +++ b/packages/server/src/services/events.ts @@ -0,0 +1,17 @@ +/** + * Server event envelope types. + * + * Summary: + * Defines the structured event envelope used by the HTTP/SSE server. Event + * production remains in the runtime service until the runtime class is split. + * + * Exports: + * - type HeadlessEvent + */ +export type HeadlessEvent = { + type: string; + requestId?: string; + sequence?: number; + timestamp?: string; + [key: string]: unknown; +}; diff --git a/packages/server/src/services/http-server.ts b/packages/server/src/services/http-server.ts new file mode 100644 index 00000000..12a68897 --- /dev/null +++ b/packages/server/src/services/http-server.ts @@ -0,0 +1,132 @@ +/** + * HTTP/SSE server service entry. + * + * Summary: + * Starts the standalone local HTTP/SSE runtime host and wires request handling to + * the split service modules. + * + * Exports: + * - runHeadlessHttp(options: HeadlessOptions): Promise + * - type HeadlessOptions + */ +import crypto from "node:crypto"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { isAuthorized } from "./auth"; +import { shutdownServer } from "./lifecycle"; +import { sendJson } from "./response"; +import { routeRequest } from "./routes"; +import { parseServerOptions } from "./server-options"; +import { openSseStream } from "./sse"; +import { ServerRuntimeService } from "./runtime"; + +export type HeadlessOptions = { + args: string[]; + projectRoot: string; + version: string; +}; + +export async function runHeadlessHttp(options: HeadlessOptions): Promise { + const { host, port, authDisabled } = parseServerOptions(options.args); + if (authDisabled) { + process.stderr.write("Warning: deepcode server auth is disabled. Use only in a trusted local dev environment.\n"); + } + + const accessToken = authDisabled ? null : crypto.randomUUID(); + const runtime = new ServerRuntimeService(options.projectRoot); + await runtime.init(); + + const activeResponses = new Set(); + const httpServer = createServer(async (request, response) => { + activeResponses.add(response); + response.on("close", () => activeResponses.delete(response)); + try { + setBaseHeaders(request, response); + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + if (accessToken && !isAuthorized(request, accessToken)) { + sendJson(response, 401, { ok: false, error: "Unauthorized" }); + return; + } + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = url.pathname.replace(/\/+$/u, "") || "/"; + if (request.method === "GET" && pathname === "/events") { + openSseStream(request, response, runtime); + return; + } + await routeRequest({ + request, + response, + runtime, + version: options.version, + projectRoot: options.projectRoot, + shutdown: () => shutdown(), + }); + } catch (error) { + sendJson(response, statusCodeFromError(error), { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + + let shuttingDown = false; + const shutdown = (): void => { + if (shuttingDown) { + return; + } + shuttingDown = true; + runtime.notifyShutdown(); + shutdownServer(httpServer, activeResponses); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + + await new Promise((resolve) => { + httpServer.listen(port, host, resolve); + }); + + const authHint = accessToken ? ` token=${accessToken}` : " auth=disabled"; + process.stdout.write(`deepcode server listening on http://${host}:${port}${authHint}\n`); + + await new Promise((resolve) => { + httpServer.on("close", resolve); + }); + runtime.dispose(); +} + +function setBaseHeaders(request: IncomingMessage, response: ServerResponse): void { + const origin = request.headers.origin; + response.setHeader("Access-Control-Allow-Origin", isAllowedLocalOrigin(origin) ? origin : "http://127.0.0.1"); + response.setHeader("Vary", "Origin"); + response.setHeader("Access-Control-Allow-Headers", "content-type, x-deepcode-token, authorization"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); +} + +function isAllowedLocalOrigin(origin: unknown): origin is string { + if (typeof origin !== "string") { + return false; + } + try { + const url = new URL(origin); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + (url.hostname === "127.0.0.1" || url.hostname === "localhost") + ); + } catch { + return false; + } +} + +function statusCodeFromError(error: unknown): number { + if (isRecord(error) && typeof error.statusCode === "number" && Number.isInteger(error.statusCode)) { + return error.statusCode; + } + return 500; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/server/src/services/images.ts b/packages/server/src/services/images.ts new file mode 100644 index 00000000..3de072b1 --- /dev/null +++ b/packages/server/src/services/images.ts @@ -0,0 +1,135 @@ +/** + * Prompt image normalization helpers. + * + * Summary: + * Converts image payload inputs into data URLs or remote image URLs for user + * prompt content. This service owns image path validation, local file loading, + * and MIME inference. + * + * Exports: + * - normalizeImageList(projectRoot: string, value: unknown): { ok: true; data: string[] } | { ok: false; error: string } + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { normalizeProjectFilePath } from "./open-file"; + +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; + +export function normalizeImageList( + projectRoot: string, + value: unknown +): { ok: true; data: string[] } | { ok: false; error: string } { + const items = Array.isArray(value) ? value : value === undefined ? [] : [value]; + const imageUrls: string[] = []; + for (const item of items) { + const normalized = normalizeImageItem(projectRoot, item); + if (!normalized.ok) { + return normalized; + } + if (normalized.data) { + imageUrls.push(normalized.data); + } + } + return { ok: true, data: imageUrls }; +} + +function normalizeImageItem( + projectRoot: string, + item: unknown +): { ok: true; data: string | null } | { ok: false; error: string } { + if (typeof item === "string") { + return normalizeImageString(projectRoot, item); + } + if (!isRecord(item)) { + return { ok: false, error: "Image item must be a string or object" }; + } + if (typeof item.dataUrl === "string") { + return normalizeImageString(projectRoot, item.dataUrl); + } + if (typeof item.url === "string") { + return normalizeImageString(projectRoot, item.url); + } + if (typeof item.filePath === "string") { + return readImageFileAsDataUrl(projectRoot, item.filePath); + } + if (typeof item.path === "string") { + return readImageFileAsDataUrl(projectRoot, item.path); + } + return { ok: false, error: "Image object requires dataUrl, url, filePath, or path" }; +} + +function normalizeImageString( + projectRoot: string, + value: string +): { ok: true; data: string | null } | { ok: false; error: string } { + const trimmed = value.trim(); + if (!trimmed) { + return { ok: true, data: null }; + } + if (trimmed.startsWith("data:image/")) { + return { ok: true, data: trimmed }; + } + if (trimmed.startsWith("blob:")) { + return { ok: false, error: "blob image URLs must be converted before sending to the server" }; + } + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return { ok: true, data: trimmed }; + } + if (trimmed.startsWith("file://")) { + try { + return readImageFileAsDataUrl(projectRoot, fileURLToPath(trimmed)); + } catch { + return { ok: false, error: "Invalid file URL image" }; + } + } + return readImageFileAsDataUrl(projectRoot, trimmed); +} + +function readImageFileAsDataUrl( + projectRoot: string, + filePath: string +): { ok: true; data: string } | { ok: false; error: string } { + const request = normalizeProjectFilePath(projectRoot, filePath); + if (!request.ok) { + return request; + } + let stat: fs.Stats; + try { + stat = fs.statSync(request.data.absolutePath); + } catch { + return { ok: false, error: `Image file not found: ${request.data.relativePath}` }; + } + if (!stat.isFile()) { + return { ok: false, error: `Image path is not a file: ${request.data.relativePath}` }; + } + if (stat.size > MAX_IMAGE_BYTES) { + return { ok: false, error: `Image file is too large: ${request.data.relativePath}` }; + } + const mime = getImageMimeType(request.data.absolutePath); + if (!mime) { + return { ok: false, error: `Unsupported image type: ${request.data.relativePath}` }; + } + return { ok: true, data: `data:${mime};base64,${fs.readFileSync(request.data.absolutePath).toString("base64")}` }; +} + +function getImageMimeType(filePath: string): string | null { + const ext = path.extname(filePath).toLowerCase(); + if (ext === ".png") { + return "image/png"; + } + if (ext === ".jpg" || ext === ".jpeg") { + return "image/jpeg"; + } + if (ext === ".gif") { + return "image/gif"; + } + if (ext === ".webp") { + return "image/webp"; + } + return null; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/server/src/services/lifecycle.ts b/packages/server/src/services/lifecycle.ts new file mode 100644 index 00000000..13d33ca3 --- /dev/null +++ b/packages/server/src/services/lifecycle.ts @@ -0,0 +1,26 @@ +/** + * Server lifecycle helpers. + * + * Summary: + * Owns graceful HTTP server shutdown behavior and active response cleanup. + * + * Exports: + * - shutdownServer(httpServer: ReturnType, activeResponses: Set): void + */ +import type { createServer, ServerResponse } from "node:http"; + +export function shutdownServer( + httpServer: ReturnType, + activeResponses: Set +): void { + for (const response of activeResponses) { + if (!response.writableEnded) { + try { + response.end(); + } catch { + // Ignore close failures during shutdown. + } + } + } + httpServer.close(); +} diff --git a/packages/server/src/services/model-config.ts b/packages/server/src/services/model-config.ts new file mode 100644 index 00000000..57a9e679 --- /dev/null +++ b/packages/server/src/services/model-config.ts @@ -0,0 +1,70 @@ +/** + * Model configuration helpers. + * + * Summary: + * Builds and validates the model configuration payload exposed by GET /model and + * accepted by POST /model. This keeps model option handling outside HTTP route + * dispatch and independent from CLI UI code. + * + * Exports: + * - buildAvailableModelOptions(): ModelOption[] + * - buildReasoningEffortOptions(): ReasoningEffort[] + * - buildThinkingOptions(): boolean[] + * - normalizeModelSelection(body: RequestBody, current: ResolvedDeepcodingSettings) + * - type ModelOption + */ +import { + defaultsToThinkingMode, + supportsMultimodal, + type ModelConfigSelection, + type ReasoningEffort, + type ResolvedDeepcodingSettings, +} from "@vegamo/deepcode-core"; +import { MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS } from "../model-options"; +import type { RequestBody } from "./request-body"; + +export type ModelOption = { + model: string; + thinkingDefault: boolean; + supportsMultimodal: boolean; +}; + +export function buildAvailableModelOptions(): ModelOption[] { + return MODEL_COMMAND_MODELS.map((model) => ({ + model, + thinkingDefault: defaultsToThinkingMode(model), + supportsMultimodal: supportsMultimodal(model), + })); +} + +export function buildReasoningEffortOptions(): ReasoningEffort[] { + const efforts = MODEL_COMMAND_THINKING_OPTIONS.map((option) => option.reasoningEffort).filter( + (effort): effort is ReasoningEffort => effort === "high" || effort === "max" + ); + return Array.from(new Set(efforts)); +} + +export function buildThinkingOptions(): boolean[] { + return Array.from(new Set(MODEL_COMMAND_THINKING_OPTIONS.map((option) => option.thinkingEnabled))); +} + +export function normalizeModelSelection( + body: RequestBody, + current: ResolvedDeepcodingSettings +): { ok: true; data: ModelConfigSelection } | { ok: false; error: string } { + const model = typeof body.model === "string" && body.model.trim() ? body.model.trim() : current.model; + const thinkingEnabled = typeof body.thinkingEnabled === "boolean" ? body.thinkingEnabled : current.thinkingEnabled; + const hasReasoningEffort = Object.prototype.hasOwnProperty.call(body, "reasoningEffort"); + const requestedReasoningEffort = hasReasoningEffort ? normalizeReasoningEffort(body.reasoningEffort) : undefined; + if (hasReasoningEffort && !requestedReasoningEffort) { + return { ok: false, error: "reasoningEffort must be high or max" }; + } + return { + ok: true, + data: { model, thinkingEnabled, reasoningEffort: requestedReasoningEffort ?? current.reasoningEffort }, + }; +} + +function normalizeReasoningEffort(value: unknown): ReasoningEffort | undefined { + return value === "high" || value === "max" ? value : undefined; +} diff --git a/packages/server/src/services/open-file.ts b/packages/server/src/services/open-file.ts new file mode 100644 index 00000000..99179a36 --- /dev/null +++ b/packages/server/src/services/open-file.ts @@ -0,0 +1,54 @@ +/** + * Project file opening helpers. + * + * Summary: + * Normalizes project-local file paths and builds platform-specific opener command + * candidates for editor/file open requests. + * + * Exports: + * - normalizeProjectFilePath(projectRoot: string, filePath: string) + * - getOpenFileCommands(filePath: string, line: number): OpenFileCommand[] + * - type OpenFileRequest + * - type OpenFileCommand + */ +import * as path from "node:path"; + +export type OpenFileRequest = { + absolutePath: string; + relativePath: string; + line: number; +}; + +export type OpenFileCommand = { + command: string; + args: string[]; +}; + +export function normalizeProjectFilePath( + projectRoot: string, + filePath: string +): { ok: true; data: Omit } | { ok: false; error: string } { + const trimmedPath = filePath.trim(); + if (!trimmedPath) { + return { ok: false, error: "filePath is required" }; + } + const root = path.resolve(projectRoot); + const absolutePath = path.resolve(path.isAbsolute(trimmedPath) ? trimmedPath : path.join(root, trimmedPath)); + const relativePath = path.relative(root, absolutePath); + if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return { ok: false, error: "filePath must point to a file inside the project root" }; + } + return { ok: true, data: { absolutePath, relativePath } }; +} + +export function getOpenFileCommands(filePath: string, line: number): OpenFileCommand[] { + const commands: OpenFileCommand[] = [{ command: "code", args: ["-g", `${filePath}:${line}`] }]; + if (process.platform === "darwin") { + commands.push({ command: "open", args: [filePath] }); + } else if (process.platform === "win32") { + commands.push({ command: "cmd.exe", args: ["/c", "start", "", filePath] }); + } else { + commands.push({ command: "xdg-open", args: [filePath] }); + } + return commands; +} diff --git a/packages/server/src/services/permissions.ts b/packages/server/src/services/permissions.ts new file mode 100644 index 00000000..6a6403f7 --- /dev/null +++ b/packages/server/src/services/permissions.ts @@ -0,0 +1,58 @@ +/** + * Permission request normalization helpers. + * + * Summary: + * Converts frontend permission reply payloads into core UserToolPermission and + * PermissionScope values. + * + * Exports: + * - normalizeUserPermissions(value: unknown): UserToolPermission[] + * - normalizePermissionScopes(value: unknown): PermissionScope[] | undefined + */ +import type { PermissionScope, UserToolPermission } from "@vegamo/deepcode-core"; + +const VALID_PERMISSION_SCOPES = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", +]); + +export function normalizeUserPermissions(value: unknown): UserToolPermission[] { + const entries = Array.isArray(value) + ? value + : isRecord(value) + ? Object.entries(value).map(([toolCallId, permission]) => ({ toolCallId, permission })) + : []; + return entries + .map((item) => { + if (!isRecord(item) || typeof item.toolCallId !== "string") { + return null; + } + if (item.permission !== "allow" && item.permission !== "deny") { + return null; + } + return { toolCallId: item.toolCallId, permission: item.permission } satisfies UserToolPermission; + }) + .filter((item): item is UserToolPermission => item !== null); +} + +export function normalizePermissionScopes(value: unknown): PermissionScope[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const scopes = value.filter( + (item): item is PermissionScope => typeof item === "string" && VALID_PERMISSION_SCOPES.has(item as PermissionScope) + ); + return scopes.length > 0 ? Array.from(new Set(scopes)) : undefined; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/server/src/services/prompt-content.ts b/packages/server/src/services/prompt-content.ts new file mode 100644 index 00000000..b82d21e1 --- /dev/null +++ b/packages/server/src/services/prompt-content.ts @@ -0,0 +1,48 @@ +/** + * Prompt content helpers. + * + * Summary: + * Converts request payloads into core UserPromptContent values. This module is + * intentionally limited to payload normalization and does not execute prompts. + * + * Exports: + * - buildPromptContent(projectRoot: string, body: RequestBody) + */ +import type { SkillInfo, UserPromptContent } from "@vegamo/deepcode-core"; +import { normalizeImageList } from "./images"; +import { normalizePermissionScopes, normalizeUserPermissions } from "./permissions"; +import type { RequestBody } from "./request-body"; + +export function buildPromptContent( + projectRoot: string, + body: RequestBody +): { ok: true; data: UserPromptContent } | { ok: false; error: string } { + const text = typeof body.text === "string" ? body.text : typeof body.prompt === "string" ? body.prompt : ""; + const images = normalizeImageList(projectRoot, body.images ?? body.imageUrls); + if (!images.ok) { + return images; + } + const userPermissions = normalizeUserPermissions(body.permissions ?? body.decisions); + return { + ok: true, + data: { + text, + skills: normalizeSkillList(body.skills), + imageUrls: images.data.length > 0 ? images.data : undefined, + permissions: userPermissions.length > 0 ? userPermissions : undefined, + alwaysAllows: normalizePermissionScopes(body.alwaysAllows), + }, + }; +} + +function normalizeSkillList(value: unknown): SkillInfo[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const skills = value.filter((item): item is SkillInfo => isRecord(item) && typeof item.name === "string"); + return skills.length > 0 ? skills : undefined; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/server/src/services/request-body.ts b/packages/server/src/services/request-body.ts new file mode 100644 index 00000000..8fc0ffd3 --- /dev/null +++ b/packages/server/src/services/request-body.ts @@ -0,0 +1,64 @@ +/** + * Request body parsing helpers. + * + * Summary: + * Reads JSON request bodies with a byte limit and returns typed request payloads. + * This module deliberately contains only request parsing and validation concerns. + * + * Exports: + * - readJsonBody(request: IncomingMessage): Promise + * - type RequestBody + */ +import type { IncomingMessage } from "node:http"; + +export type RequestBody = { + text?: unknown; + prompt?: unknown; + skills?: unknown; + images?: unknown; + imageUrls?: unknown; + sessionId?: unknown; + permissions?: unknown; + alwaysAllows?: unknown; + decisions?: unknown; + mode?: unknown; + filePath?: unknown; + path?: unknown; + line?: unknown; + messageId?: unknown; + restoreCode?: unknown; + restoreConversation?: unknown; + summary?: unknown; + name?: unknown; + model?: unknown; + thinkingEnabled?: unknown; + reasoningEffort?: unknown; + deltaMs?: unknown; +}; + +const MAX_BODY_BYTES = 2 * 1024 * 1024; + +export async function readJsonBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + size += buffer.length; + if (size > MAX_BODY_BYTES) { + throw Object.assign(new Error("Request body too large"), { statusCode: 413 }); + } + chunks.push(buffer); + } + if (chunks.length === 0) { + return {}; + } + const raw = Buffer.concat(chunks).toString("utf8").trim(); + if (!raw) { + return {}; + } + try { + return JSON.parse(raw) as RequestBody; + } catch { + throw Object.assign(new Error("Invalid JSON body"), { statusCode: 400 }); + } +} diff --git a/packages/server/src/services/response.ts b/packages/server/src/services/response.ts new file mode 100644 index 00000000..b618495c --- /dev/null +++ b/packages/server/src/services/response.ts @@ -0,0 +1,61 @@ +/** + * JSON response serialization helpers. + * + * Summary: + * Serializes service payloads to HTTP responses and normalizes failed payloads + * into stable HTTP status codes. + * + * Exports: + * - sendJson(response: ServerResponse, statusCode: number, payload: JsonValue): void + * - statusCodeForFailure(payload: { ok: false; error?: unknown }): number + */ +import type { ServerResponse } from "node:http"; +import type { JsonValue } from "./types"; + +export function sendJson(response: ServerResponse, statusCode: number, payload: JsonValue): void { + if (response.headersSent) { + return; + } + const finalStatusCode = statusCode < 400 && isFailurePayload(payload) ? statusCodeForFailure(payload) : statusCode; + response.writeHead(finalStatusCode, { "content-type": "application/json; charset=utf-8" }); + response.end(`${JSON.stringify(payload, null, 2)}\n`); +} + +export function statusCodeForFailure(payload: Record & { ok: false; error?: unknown }): number { + const error = typeof payload.error === "string" ? payload.error.toLowerCase() : ""; + if (error.includes("not found")) { + return 404; + } + if ( + error.includes("required") || + error.includes("invalid") || + error.includes("unsupported") || + error.includes("too large") || + error.includes("must") || + error.includes("no permission replies") + ) { + return 400; + } + if ( + error.includes("busy") || + error.includes("no active") || + error.includes("pending") || + error.includes("permission request") || + error.includes("permission mismatch") || + error.includes("permission denied") || + error.includes("conflict") || + error.includes("state") || + error.includes("adjustable") + ) { + return 409; + } + return 400; +} + +function isFailurePayload(payload: JsonValue): payload is Record & { ok: false; error?: unknown } { + return isRecord(payload) && payload.ok === false; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/server/src/services/routes.ts b/packages/server/src/services/routes.ts new file mode 100644 index 00000000..614d621e --- /dev/null +++ b/packages/server/src/services/routes.ts @@ -0,0 +1,161 @@ +/** + * HTTP route dispatch service. + * + * Summary: + * Maps local server HTTP paths to runtime actions. This service owns request path + * dispatch only; runtime behavior stays behind the shared ServerRuntime contract. + * + * Exports: + * - routeRequest(input: RouteRequestInput): Promise + * - type RouteRequestInput + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { buildHeadlessCommandRoutes, findHeadlessCommandRoute } from "../command-map"; +import { buildPromptContent } from "./prompt-content"; +import { readJsonBody } from "./request-body"; +import type { ServerRuntime } from "./runtime-contract"; +import { sendJson } from "./response"; + +export type RouteRequestInput = { + request: IncomingMessage; + response: ServerResponse; + runtime: ServerRuntime; + version: string; + projectRoot: string; + shutdown: () => void; +}; + +export async function routeRequest(input: RouteRequestInput): Promise { + const { request, response, runtime, version, projectRoot, shutdown } = input; + const method = request.method ?? "GET"; + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = url.pathname.replace(/\/+$/u, "") || "/"; + + if ((method === "GET" || method === "POST") && pathname === "/ready") { + return sendJson(response, 200, await runtime.ready()); + } + if (method === "GET" && pathname === "/health") { + return sendJson(response, 200, { ok: true, data: { version, projectRoot } }); + } + if (method === "GET" && pathname === "/version") { + return sendJson(response, 200, { ok: true, data: { version } }); + } + if (method === "GET" && pathname === "/commands") { + return sendJson(response, 200, { ok: true, data: buildHeadlessCommandRoutes() }); + } + if (method === "GET" && pathname === "/model") { + return sendJson(response, 200, runtime.getModelConfig()); + } + if (method === "POST" && pathname === "/model") { + return sendJson(response, 200, runtime.updateModelConfig(await readJsonBody(request))); + } + if (method === "GET" && pathname === "/processes") { + return sendJson(response, 200, runtime.listProcesses()); + } + if (method === "POST" && pathname === "/processes/timeout") { + return sendJson(response, 200, runtime.adjustProcessTimeout(await readJsonBody(request))); + } + if (method === "GET" && pathname === "/sessions") { + return sendJson(response, 200, { ok: true, data: runtime.listSessions() }); + } + if (method === "POST" && pathname === "/sessions/rename") { + return sendJson(response, 200, runtime.renameSession(await readJsonBody(request))); + } + if (method === "POST" && pathname === "/sessions/delete") { + return sendJson(response, 200, runtime.deleteSession(await readJsonBody(request))); + } + if ((method === "GET" || method === "POST") && pathname === "/request-skills") { + return sendJson(response, 200, await runtime.sendSkillsList()); + } + if ((method === "GET" || method === "POST") && pathname === "/back-to-list") { + return sendJson(response, 200, runtime.showSessionsList()); + } + if (method === "POST" && (pathname === "/open-file" || pathname === "/openFile")) { + return sendJson(response, 200, runtime.openFile(await readJsonBody(request))); + } + if (method === "GET" && pathname === "/permissions/pending") { + return sendJson(response, 200, runtime.pendingPermissions()); + } + if (method === "POST" && pathname === "/permissions/reply") { + return sendJson(response, 202, runtime.replyPermissions(await readJsonBody(request))); + } + if (method === "POST" && pathname === "/select-session") { + const body = await readJsonBody(request); + return sendJson(response, 200, await runtime.selectSession(String(body.sessionId ?? ""))); + } + if (method === "POST" && pathname === "/prompt") { + const prompt = buildPromptContent(projectRoot, await readJsonBody(request)); + return sendJson( + response, + prompt.ok ? 202 : 400, + prompt.ok ? runtime.startPrompt(prompt.data) : { ok: false, error: prompt.error } + ); + } + if (method === "POST" && pathname === "/interrupt") { + return sendJson(response, 200, runtime.interrupt()); + } + if (method === "POST" && pathname === "/undo/restore") { + return sendJson(response, 200, runtime.restoreUndo(await readJsonBody(request))); + } + if (method === "POST" && pathname === "/undo/restore-code") { + return sendJson( + response, + 200, + runtime.restoreUndo(await readJsonBody(request), { restoreCode: true, restoreConversation: false }) + ); + } + if (method === "POST" && pathname === "/undo/restore-conversation") { + return sendJson( + response, + 200, + runtime.restoreUndo(await readJsonBody(request), { restoreCode: false, restoreConversation: true }) + ); + } + if (method === "POST" && pathname === "/exit") { + sendJson(response, 200, { ok: true }); + setTimeout(shutdown, 0); + return; + } + + const command = findHeadlessCommandRoute(pathname); + if (!command) { + return sendJson(response, 404, { ok: false, error: "Not found" }); + } + if (method !== command.method && !(command.name === "undo" && method === "GET")) { + return sendJson(response, 405, { ok: false, error: `Use ${command.method} ${command.path}` }); + } + if (!command.implemented) { + return sendJson(response, 501, { + ok: false, + error: `Command ${command.label} is not implemented in server mode yet.`, + }); + } + if (command.name === "skills") { + return sendJson(response, 200, await runtime.sendSkillsList()); + } + if (command.name === "mcp") { + return sendJson(response, 200, { ok: true, data: runtime.getMcpStatus() }); + } + if (command.name === "resume") { + return sendJson(response, 200, runtime.showSessionsList()); + } + if (command.name === "new") { + return sendJson(response, 200, await runtime.newSession()); + } + if (command.name === "undo") { + return sendJson(response, 200, runtime.undoTargets()); + } + if (command.name === "exit") { + sendJson(response, 200, { ok: true }); + setTimeout(shutdown, 0); + return; + } + + const body = method === "POST" ? await readJsonBody(request) : {}; + const prompt = buildPromptContent(projectRoot, { ...body, text: `/${command.name}` }); + return sendJson( + response, + prompt.ok ? 202 : 400, + prompt.ok ? runtime.startPrompt(prompt.data) : { ok: false, error: prompt.error } + ); +} diff --git a/packages/server/src/services/runtime-contract.ts b/packages/server/src/services/runtime-contract.ts new file mode 100644 index 00000000..8fecdb27 --- /dev/null +++ b/packages/server/src/services/runtime-contract.ts @@ -0,0 +1,38 @@ +/** + * Runtime contract for HTTP services. + * + * Summary: + * Defines the runtime surface consumed by route and SSE services. The concrete + * runtime implementation lives in runtime.ts and the HTTP wiring consumes this + * contract through the split service modules. + * + * Exports: + * - type ServerRuntime + */ +import type { HeadlessEvent } from "./events"; +import type { RequestBody } from "./request-body"; +import type { JsonValue } from "./types"; + +export type ServerRuntime = { + subscribe(listener: (event: HeadlessEvent) => void): () => void; + ready(): Promise; + getModelConfig(): JsonValue; + updateModelConfig(body: RequestBody): JsonValue; + listProcesses(): JsonValue; + adjustProcessTimeout(body: RequestBody): JsonValue; + listSessions(): JsonValue; + renameSession(body: RequestBody): JsonValue; + deleteSession(body: RequestBody): JsonValue; + sendSkillsList(): Promise; + showSessionsList(): JsonValue; + openFile(body: RequestBody): JsonValue; + pendingPermissions(): JsonValue; + replyPermissions(body: RequestBody): JsonValue; + selectSession(sessionId: string): Promise; + startPrompt(prompt: unknown): JsonValue; + interrupt(): JsonValue; + restoreUndo(body: RequestBody, defaults?: { restoreCode?: boolean; restoreConversation?: boolean }): JsonValue; + newSession(): Promise; + undoTargets(): JsonValue; + getMcpStatus(): unknown[]; +}; diff --git a/packages/server/src/services/runtime.ts b/packages/server/src/services/runtime.ts new file mode 100644 index 00000000..aa9404b1 --- /dev/null +++ b/packages/server/src/services/runtime.ts @@ -0,0 +1,548 @@ +/** + * DeepCode server runtime service. + * + * Summary: + * Owns the SessionManager-backed runtime surface used by HTTP routes and SSE. + * This module moves the concrete runtime class out of the legacy HTTP server + * shell and implements the shared ServerRuntime contract. + * + * Exports: + * - ServerRuntimeService(projectRoot: string) + */ +import crypto from "node:crypto"; +import { spawn } from "node:child_process"; +import { + createOpenAIClient, + getCompactPromptTokenThreshold, + resolveCurrentSettings, + SessionManager, + writeModelConfigSelection, + type ResolvedDeepcodingSettings, + type SessionEntry, + type UserPromptContent, +} from "@vegamo/deepcode-core"; +import type { HeadlessEvent } from "./events"; +import { + buildAvailableModelOptions, + buildReasoningEffortOptions, + buildThinkingOptions, + normalizeModelSelection, +} from "./model-config"; +import { getOpenFileCommands, normalizeProjectFilePath, type OpenFileCommand, type OpenFileRequest } from "./open-file"; +import { normalizePermissionScopes, normalizeUserPermissions } from "./permissions"; +import type { RequestBody } from "./request-body"; +import type { ServerRuntime } from "./runtime-contract"; +import { serializeMessage, serializeProcesses } from "./session-serialization"; +import type { JsonValue } from "./types"; + +export class ServerRuntimeService implements ServerRuntime { + private readonly listeners = new Set<(event: HeadlessEvent) => void>(); + private readonly sessionManager: SessionManager; + private activeRequestId: string | null = null; + private sequence = 0; + + constructor(private readonly projectRoot: string) { + this.sessionManager = new SessionManager({ + projectRoot, + createOpenAIClient: () => createOpenAIClient(projectRoot), + getResolvedSettings: () => resolveCurrentSettings(projectRoot), + renderMarkdown: (text) => text, + onAssistantMessage: (message, shouldConnect) => { + if (message.visible === false) { + return; + } + this.pushEvent({ type: "appendMessage", message: serializeMessage(message), shouldConnect }); + }, + onSessionEntryUpdated: (entry) => { + this.pushEvent({ + type: "sessionStatus", + sessionId: entry.id, + status: entry.status, + processes: serializeProcesses(entry.processes), + askPermissions: entry.askPermissions, + tokenTelemetry: this.buildTokenTelemetry(entry), + }); + if (entry.status === "ask_permission") { + this.pushEvent({ + type: "permissionRequest", + sessionId: entry.id, + askPermissions: entry.askPermissions ?? [], + }); + } + }, + onLlmStreamProgress: (progress) => this.pushEvent({ type: "llmStreamProgress", progress }), + onMcpStatusChanged: () => this.pushEvent({ type: "mcpStatus", statuses: this.getMcpStatus() }), + onProcessStdout: (pid, chunk) => this.pushEvent({ type: "processStdout", pid, chunk }), + }); + } + + async init(): Promise { + await this.sessionManager.initMcpServers(resolveCurrentSettings(this.projectRoot).mcpServers); + } + + dispose(): void { + this.sessionManager.dispose(); + this.listeners.clear(); + } + + notifyShutdown(): void { + this.pushEvent({ type: "shutdown" }); + } + + subscribe(listener: (event: HeadlessEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async ready(): Promise { + const events: HeadlessEvent[] = []; + events.push(this.pushEvent(this.buildInitialSessionEvent())); + events.push(this.pushEvent(await this.buildSkillsListEvent())); + events.push( + this.pushEvent({ type: "modelConfig", config: this.buildModelConfig(resolveCurrentSettings(this.projectRoot)) }) + ); + return { ok: true, data: { events } }; + } + + listSessions(): JsonValue { + return this.sessionManager.listSessions() as unknown as JsonValue; + } + + async sendSkillsList(sessionId?: string): Promise { + const event = await this.buildSkillsListEvent(sessionId); + this.pushEvent(event); + return { ok: true, data: event }; + } + + getMcpStatus(): unknown[] { + return this.sessionManager.getMcpStatus(); + } + + getModelConfig(): JsonValue { + return { ok: true, data: this.buildModelConfig(resolveCurrentSettings(this.projectRoot)) }; + } + + updateModelConfig(body: RequestBody): JsonValue { + const current = resolveCurrentSettings(this.projectRoot); + const selected = normalizeModelSelection(body, current); + if (!selected.ok) { + return { ok: false, error: selected.error }; + } + const result = writeModelConfigSelection(selected.data, current, this.projectRoot); + const next = resolveCurrentSettings(this.projectRoot); + const event = this.pushEvent({ type: "modelConfig", config: this.buildModelConfig(next), changed: result.changed }); + return { ok: true, data: event }; + } + + listProcesses(): JsonValue { + const sessionId = this.sessionManager.getActiveSessionId(); + if (!sessionId) { + return { ok: false, error: "No active session" }; + } + const session = this.sessionManager.getSession(sessionId); + if (!session) { + return { ok: false, error: "Session not found" }; + } + return { ok: true, data: { sessionId, processes: serializeProcesses(session.processes) } }; + } + + adjustProcessTimeout(body: RequestBody): JsonValue { + const delta = normalizeDeltaMs(body.deltaMs); + if (!delta.ok) { + return { ok: false, error: delta.error }; + } + const result = this.sessionManager.adjustActiveBashTimeout(delta.data); + if (!result) { + return { ok: false, error: "No adjustable active bash timeout" }; + } + this.pushActiveSessionStatus(); + return { ok: true, data: result as JsonValue }; + } + + showSessionsList(): JsonValue { + const event = { type: "showSessionsList", sessions: this.buildSessionsList() }; + this.pushEvent(event); + return { ok: true, data: event }; + } + + async selectSession(sessionId: string): Promise { + const session = this.sessionManager.getSession(sessionId); + if (!session) { + return { ok: false, error: "Session not found" }; + } + this.sessionManager.setActiveSessionId(sessionId); + const loadEvent = this.buildLoadSessionEvent(session); + const skillsEvent = await this.buildSkillsListEvent(sessionId); + this.pushEvent(loadEvent); + this.pushEvent(skillsEvent); + return { ok: true, data: { events: [loadEvent, skillsEvent] } }; + } + + async newSession(): Promise { + if (this.activeRequestId) { + return { ok: false, error: "DeepCode is busy" }; + } + this.sessionManager.setActiveSessionId(null); + const initEvent = this.buildInitializeEmptyEvent(); + const skillsEvent = await this.buildSkillsListEvent(); + this.pushEvent(initEvent); + this.pushEvent(skillsEvent); + return { ok: true, data: { events: [initEvent, skillsEvent] } }; + } + + interrupt(): JsonValue { + const sessionId = this.sessionManager.getActiveSessionId(); + this.sessionManager.interruptActiveSession(); + this.pushActiveSessionStatus(); + const session = sessionId ? this.sessionManager.getSession(sessionId) : null; + return { ok: true, data: { sessionId, status: session?.status ?? null } }; + } + + openFile(body: RequestBody): JsonValue { + const request = normalizeOpenFileRequest(this.projectRoot, body); + if (!request.ok) { + return { ok: false, error: request.error }; + } + const opened = launchOpenFile(request.data, (error) => { + this.pushEvent({ + type: "openFileFailed", + filePath: request.data.relativePath, + absolutePath: request.data.absolutePath, + line: request.data.line, + error: error instanceof Error ? error.message : String(error), + }); + }); + const event = this.pushEvent({ + type: "openFile", + filePath: request.data.relativePath, + absolutePath: request.data.absolutePath, + line: request.data.line, + opener: opened, + }); + return { ok: true, data: event }; + } + + undoTargets(): JsonValue { + const sessionId = this.sessionManager.getActiveSessionId(); + if (!sessionId) { + return { ok: false, error: "No active session" }; + } + return { ok: true, data: this.sessionManager.listUndoTargets(sessionId) as unknown as JsonValue }; + } + + restoreUndo(body: RequestBody, defaults: { restoreCode?: boolean; restoreConversation?: boolean } = {}): JsonValue { + const sessionId = normalizeSessionId(body.sessionId, this.sessionManager.getActiveSessionId()); + if (!sessionId) { + return { ok: false, error: "No active session" }; + } + const messageId = typeof body.messageId === "string" ? body.messageId.trim() : ""; + if (!messageId) { + return { ok: false, error: "messageId is required" }; + } + const restoreCode = defaults.restoreCode ?? body.restoreCode === true; + const restoreConversation = defaults.restoreConversation ?? body.restoreConversation !== false; + if (!restoreCode && !restoreConversation) { + return { ok: false, error: "restoreCode or restoreConversation must be true" }; + } + try { + if (restoreCode) { + this.sessionManager.restoreSessionCode(sessionId, messageId); + } + if (restoreConversation) { + this.sessionManager.restoreSessionConversation(sessionId, messageId); + } + const events: HeadlessEvent[] = []; + const session = this.sessionManager.getSession(sessionId); + if (session) { + const loadEvent = this.buildLoadSessionEvent(session); + this.pushEvent(loadEvent); + events.push(loadEvent); + } + const listEvent = { type: "showSessionsList", sessions: this.buildSessionsList() }; + this.pushEvent(listEvent); + events.push(listEvent); + return { + ok: true, + data: { sessionId, messageId, restoredCode: restoreCode, restoredConversation: restoreConversation, events }, + }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + renameSession(body: RequestBody): JsonValue { + const sessionId = normalizeSessionId(body.sessionId, this.sessionManager.getActiveSessionId()); + const summary = typeof body.summary === "string" ? body.summary : typeof body.name === "string" ? body.name : ""; + if (!sessionId) { + return { ok: false, error: "sessionId is required" }; + } + if (!summary.trim()) { + return { ok: false, error: "summary is required" }; + } + const renamed = this.sessionManager.renameSession(sessionId, summary); + if (!renamed) { + return { ok: false, error: "Session not found or summary is empty" }; + } + const event = { type: "showSessionsList", sessions: this.buildSessionsList() }; + this.pushEvent(event); + this.pushActiveSessionStatus(); + return { ok: true, data: event }; + } + + deleteSession(body: RequestBody): JsonValue { + const sessionId = normalizeSessionId(body.sessionId, this.sessionManager.getActiveSessionId()); + if (!sessionId) { + return { ok: false, error: "sessionId is required" }; + } + const wasActive = this.sessionManager.getActiveSessionId() === sessionId; + const deleted = this.sessionManager.deleteSession(sessionId); + if (!deleted) { + return { ok: false, error: "Session not found" }; + } + const events: HeadlessEvent[] = []; + if (wasActive) { + this.sessionManager.setActiveSessionId(null); + const initEvent = this.buildInitializeEmptyEvent(); + this.pushEvent(initEvent); + events.push(initEvent); + } + const listEvent = { type: "showSessionsList", sessions: this.buildSessionsList() }; + this.pushEvent(listEvent); + events.push(listEvent); + return { ok: true, data: { sessionId, events } }; + } + + pendingPermissions(): JsonValue { + const sessionId = this.sessionManager.getActiveSessionId(); + if (!sessionId) { + return { ok: false, error: "No active session" }; + } + const session = this.sessionManager.getSession(sessionId); + if (!session) { + return { ok: false, error: "Session not found" }; + } + return { ok: true, data: { sessionId, status: session.status, askPermissions: session.askPermissions ?? [] } }; + } + + replyPermissions(body: RequestBody): JsonValue { + const sessionId = this.sessionManager.getActiveSessionId(); + if (!sessionId) { + return { ok: false, error: "No active session" }; + } + const session = this.sessionManager.getSession(sessionId); + if (!session) { + return { ok: false, error: "Session not found" }; + } + if (!session.askPermissions || session.askPermissions.length === 0) { + return { ok: false, error: "No pending permission request" }; + } + const permissions = normalizeUserPermissions(body.permissions ?? body.decisions); + if (permissions.length === 0) { + return { ok: false, error: "No permission replies provided" }; + } + const alwaysAllows = normalizePermissionScopes(body.alwaysAllows); + const hasDeny = permissions.some((permission) => permission.permission === "deny"); + const mode = typeof body.mode === "string" ? body.mode : undefined; + const text = + typeof body.text === "string" ? body.text : typeof body.prompt === "string" ? body.prompt : "/continue"; + if (hasDeny && mode === "deny-and-stop") { + this.sessionManager.denySessionPermission(sessionId); + this.pushActiveSessionStatus(); + return { ok: true, data: { sessionId, denied: true } }; + } + return this.startPrompt({ text: text.trim() || "/continue", permissions, alwaysAllows }); + } + + startPrompt(userPrompt: unknown): JsonValue { + if (this.activeRequestId) { + return { ok: false, error: "DeepCode is busy", requestId: this.activeRequestId }; + } + const requestId = crypto.randomUUID(); + this.activeRequestId = requestId; + void this.runPromptTurn(requestId, userPrompt as UserPromptContent); + return { ok: true, data: { accepted: true, requestId } }; + } + + private async runPromptTurn(requestId: string, userPrompt: UserPromptContent): Promise { + const previousRequestId = this.activeRequestId; + this.activeRequestId = requestId; + const displayPrompt = + userPrompt.text || (userPrompt.imageUrls && userPrompt.imageUrls.length > 0 ? "粘贴的图像" : ""); + this.pushEvent({ type: "userMessage", content: displayPrompt }); + this.pushEvent({ type: "loading", value: true }); + try { + await this.sessionManager.handleUserPrompt(userPrompt); + this.pushEvent(await this.buildSkillsListEvent()); + this.pushActiveSessionStatus(); + this.pushEvent({ type: "showSessionsList", sessions: this.buildSessionsList() }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.pushEvent({ type: "assistant", content: `Request failed: ${message}` }); + this.pushEvent({ type: "error", message }); + } finally { + this.pushEvent({ type: "loading", value: false }); + this.activeRequestId = previousRequestId === requestId ? null : previousRequestId; + } + } + + private pushActiveSessionStatus(): void { + const sessionId = this.sessionManager.getActiveSessionId(); + const session = sessionId ? this.sessionManager.getSession(sessionId) : null; + if (!sessionId || !session) { + return; + } + this.pushEvent({ + type: "sessionStatus", + sessionId, + status: session.status, + processes: serializeProcesses(session.processes), + askPermissions: session.askPermissions, + tokenTelemetry: this.buildTokenTelemetry(session), + }); + } + + private pushEvent(event: HeadlessEvent): HeadlessEvent { + const enriched: HeadlessEvent = { + ...event, + requestId: event.requestId ?? this.activeRequestId ?? undefined, + sequence: ++this.sequence, + timestamp: new Date().toISOString(), + }; + for (const listener of this.listeners) { + listener(enriched); + } + return enriched; + } + + private buildInitialSessionEvent(): HeadlessEvent { + const sessions = this.sessionManager.listSessions(); + if (sessions.length === 0) { + return this.buildInitializeEmptyEvent(); + } + const latestSession = sessions[0]; + this.sessionManager.setActiveSessionId(latestSession.id); + return this.buildLoadSessionEvent(latestSession); + } + + private buildInitializeEmptyEvent(): HeadlessEvent { + return { + type: "initializeEmpty", + sessions: this.buildSessionsList(), + status: null, + tokenTelemetry: this.buildTokenTelemetry(null), + }; + } + + private buildLoadSessionEvent(session: SessionEntry): HeadlessEvent { + const messages = this.sessionManager.listSessionMessages(session.id).filter((message) => message.visible); + return { + type: "loadSession", + sessionId: session.id, + summary: session.summary || "Untitled", + status: session.status, + processes: serializeProcesses(session.processes), + tokenTelemetry: this.buildTokenTelemetry(session), + sessions: this.buildSessionsList(), + messages: messages.map((message) => serializeMessage(message)), + }; + } + + private async buildSkillsListEvent(sessionId?: string): Promise { + const skills = await this.sessionManager.listSkills( + sessionId ?? this.sessionManager.getActiveSessionId() ?? undefined + ); + return { type: "skillsList", skills }; + } + + private buildSessionsList(): Array< + Pick & { summary: string } + > { + return this.sessionManager.listSessions().map((session) => ({ + id: session.id, + summary: session.summary || "Untitled", + createTime: session.createTime, + updateTime: session.updateTime, + status: session.status, + })); + } + + private buildTokenTelemetry(session: SessionEntry | null): JsonValue { + const settings = resolveCurrentSettings(this.projectRoot); + return { + model: settings.model, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + activeTokens: session?.activeTokens ?? 0, + compactPromptTokenThreshold: getCompactPromptTokenThreshold(settings.model), + usage: session?.usage ?? null, + }; + } + + private buildModelConfig(settings: ResolvedDeepcodingSettings): JsonValue { + return { + model: settings.model, + baseURL: settings.baseURL, + provider: { baseURL: settings.baseURL, apiKeyConfigured: Boolean(settings.apiKey) }, + availableModels: buildAvailableModelOptions(), + reasoningEfforts: buildReasoningEffortOptions(), + thinkingOptions: buildThinkingOptions(), + temperature: settings.temperature, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + telemetryEnabled: settings.telemetryEnabled, + webSearchTool: settings.webSearchTool, + }; + } +} + +function normalizeOpenFileRequest( + projectRoot: string, + body: RequestBody +): { ok: true; data: OpenFileRequest } | { ok: false; error: string } { + const rawPath = typeof body.filePath === "string" ? body.filePath : typeof body.path === "string" ? body.path : ""; + const request = normalizeProjectFilePath(projectRoot, rawPath); + if (!request.ok) { + return request; + } + const lineNumber = Number(body.line ?? 1); + const line = Number.isInteger(lineNumber) && lineNumber > 0 ? lineNumber : 1; + return { ok: true, data: { ...request.data, line } }; +} + +function launchOpenFile(request: OpenFileRequest, onFinalError: (error: unknown) => void): OpenFileCommand | null { + const commands = getOpenFileCommands(request.absolutePath, request.line); + let index = 0; + const tryNext = (): void => { + const candidate = commands[index]; + if (!candidate) { + onFinalError(new Error("No available opener command")); + return; + } + index += 1; + try { + const child = spawn(candidate.command, candidate.args, { detached: true, stdio: "ignore" }); + child.once("error", () => tryNext()); + child.unref(); + } catch (error) { + if (index >= commands.length) { + onFinalError(error); + } else { + tryNext(); + } + } + }; + tryNext(); + return commands[0] ?? null; +} + +function normalizeDeltaMs(value: unknown): { ok: true; data: number } | { ok: false; error: string } { + const deltaMs = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN; + return Number.isFinite(deltaMs) && deltaMs !== 0 + ? { ok: true, data: deltaMs } + : { ok: false, error: "deltaMs must be a non-zero finite number" }; +} + +function normalizeSessionId(value: unknown, fallback: string | null): string | null { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} diff --git a/packages/server/src/services/server-options.ts b/packages/server/src/services/server-options.ts new file mode 100644 index 00000000..b5e7b2a3 --- /dev/null +++ b/packages/server/src/services/server-options.ts @@ -0,0 +1,43 @@ +/** + * Server option parsing helpers. + * + * Summary: + * Parses argument arrays accepted by the standalone local server package and + * preserves the local-only default binding policy. + * + * Exports: + * - parseServerOptions(args: string[]): ParsedServerOptions + * - readArgValue(args: string[], name: string): string | undefined + * - type ParsedServerOptions + */ +export type ParsedServerOptions = { + host: string; + port: number; + authDisabled: boolean; +}; + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 8787; + +export function parseServerOptions(args: string[]): ParsedServerOptions { + const host = readArgValue(args, "--host") ?? DEFAULT_HOST; + const rawPort = readArgValue(args, "--port") ?? String(DEFAULT_PORT); + const port = Number(rawPort); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid --port value: ${rawPort}`); + } + if ((host === "0.0.0.0" || host === "::") && !args.includes("--unsafe-bind")) { + throw new Error("Binding outside localhost requires --unsafe-bind."); + } + return { host, port, authDisabled: args.includes("--no-auth") }; +} + +export function readArgValue(args: string[], name: string): string | undefined { + const prefix = `${name}=`; + const inline = args.find((arg) => arg.startsWith(prefix)); + if (inline) { + return inline.slice(prefix.length); + } + const index = args.indexOf(name); + return index !== -1 && index + 1 < args.length ? args[index + 1] : undefined; +} diff --git a/packages/server/src/services/session-serialization.ts b/packages/server/src/services/session-serialization.ts new file mode 100644 index 00000000..71b62eb4 --- /dev/null +++ b/packages/server/src/services/session-serialization.ts @@ -0,0 +1,41 @@ +/** + * Session serialization helpers. + * + * Summary: + * Converts core SessionMessage and process maps into JSON-compatible payloads for + * SSE events and HTTP route responses. + * + * Exports: + * - serializeMessage(message: SessionMessage): JsonValue + * - serializeProcesses(processes: SessionEntry["processes"]): JsonValue + */ +import type { SessionEntry, SessionMessage } from "@vegamo/deepcode-core"; +import type { JsonValue } from "./types"; + +export function serializeMessage(message: SessionMessage): JsonValue { + return { + id: message.id, + sessionId: message.sessionId, + role: message.role, + content: message.content, + contentParams: message.contentParams, + messageParams: message.messageParams, + compacted: message.compacted, + visible: message.visible, + createTime: message.createTime, + updateTime: message.updateTime, + meta: message.meta, + checkpointHash: message.checkpointHash, + }; +} + +export function serializeProcesses(processes: SessionEntry["processes"]): JsonValue { + if (!processes || processes.size === 0) { + return null; + } + const result: Record = {}; + for (const [pid, entry] of processes.entries()) { + result[pid] = entry; + } + return result; +} diff --git a/packages/server/src/services/sse.ts b/packages/server/src/services/sse.ts new file mode 100644 index 00000000..c04fcb1a --- /dev/null +++ b/packages/server/src/services/sse.ts @@ -0,0 +1,35 @@ +/** + * Server-sent events stream helpers. + * + * Summary: + * Opens and writes SSE streams for frontend event subscriptions. Runtime event + * production is represented by the shared ServerRuntime contract. + * + * Exports: + * - openSseStream(request: IncomingMessage, response: ServerResponse, runtime: ServerRuntime): void + * - writeSseEvent(response: ServerResponse, eventName: string, data: JsonValue): void + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ServerRuntime } from "./runtime-contract"; +import type { JsonValue } from "./types"; + +export function openSseStream(request: IncomingMessage, response: ServerResponse, runtime: ServerRuntime): void { + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + }); + writeSseEvent(response, "connected", { type: "connected" }); + const unsubscribe = runtime.subscribe((event) => writeSseEvent(response, event.type, event)); + const timer = setInterval(() => response.write(": keep-alive\n\n"), 15000); + request.on("close", () => { + clearInterval(timer); + unsubscribe(); + }); +} + +export function writeSseEvent(response: ServerResponse, eventName: string, data: JsonValue): void { + response.write(`event: ${eventName}\n`); + response.write(`data: ${JSON.stringify(data)}\n\n`); +} diff --git a/packages/server/src/services/types.ts b/packages/server/src/services/types.ts new file mode 100644 index 00000000..277a6d2c --- /dev/null +++ b/packages/server/src/services/types.ts @@ -0,0 +1,11 @@ +/** + * Shared server service types. + * + * Summary: + * Defines JSON-like payload types shared by server services during the HTTP + * module split. + * + * Exports: + * - JsonValue + */ +export type JsonValue = Record | unknown[] | string | number | boolean | null; diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 00000000..ac00d953 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/tests"] +} diff --git a/scripts/rewrite-esm-imports.js b/scripts/rewrite-esm-imports.js index 715e5c3b..10c11c11 100644 --- a/scripts/rewrite-esm-imports.js +++ b/scripts/rewrite-esm-imports.js @@ -1,23 +1,37 @@ /** - * Post-build script: rewrites extensionless relative imports in the core - * package's dist/ output to include explicit ".js" extensions. + * Post-build script: rewrites extensionless relative imports in a built package's + * dist/ output to include explicit ".js" extensions. * - * tsc with moduleResolution:"bundler" emits `from "./foo"` (no extension). + * tsc with moduleResolution:"bundler" emits `from "./foo"` without an extension. * Node.js ESM requires `from "./foo.js"`. This script bridges the gap. + * + * Usage: + * - node scripts/rewrite-esm-imports.js rewrites packages/core/dist + * - node scripts/rewrite-esm-imports.js server rewrites packages/server/dist */ -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { globSync } from "glob"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, ".."); -const distDir = join(root, "packages", "core", "dist"); +const packageName = process.argv[2] ?? "core"; +const allowedPackages = new Set(["core", "server"]); + +if (!allowedPackages.has(packageName)) { + throw new Error(`Unsupported package for ESM import rewrite: ${packageName}`); +} + +const distDir = join(root, "packages", packageName, "dist"); +if (!existsSync(distDir)) { + throw new Error(`Cannot rewrite ESM imports because dist directory does not exist: ${distDir}`); +} const files = globSync("**/*.js", { cwd: distDir, absolute: true }); -// Match: from "./anything" or from "../anything" -// Negative lookahead: skip if already ends with .js, .json, .node, or is a bare specifier +// Match: from "./anything" or from "../anything". +// The negative lookahead skips specifiers that already end in common explicit extensions. const IMPORT_RE = /(from\s+["'])(\.\.?\/[^"']+?)(? Date: Wed, 24 Jun 2026 22:47:00 +0800 Subject: [PATCH 11/43] Harden server auth header parsing --- packages/server/src/services/auth.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/auth.ts b/packages/server/src/services/auth.ts index 30fd0b1b..8a97e03b 100644 --- a/packages/server/src/services/auth.ts +++ b/packages/server/src/services/auth.ts @@ -15,8 +15,21 @@ export function isAuthorized(request: IncomingMessage, token: string): boolean { if (url.searchParams.get("token") === token) { return true; } - if (request.headers["x-deepcode-token"] === token) { + + const tokenHeader = firstHeaderValue(request.headers["x-deepcode-token"]); + if (tokenHeader === token) { return true; } - return request.headers.authorization === `Bearer ${token}`; + + const authorization = firstHeaderValue(request.headers.authorization); + if (!authorization) { + return false; + } + + const [scheme, ...rest] = authorization.trim().split(/\s+/u); + return scheme.toLowerCase() === "bearer" && rest.join(" ") === token; +} + +function firstHeaderValue(value: string | string[] | undefined): string { + return Array.isArray(value) ? value[0] ?? "" : value ?? ""; } From 14a4bbb6fe9029a2df5c4324853c8bea57bb1f95 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 22:47:17 +0800 Subject: [PATCH 12/43] Use async filesystem reads for server images --- packages/server/src/services/images.ts | 29 +++++++++++++------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/server/src/services/images.ts b/packages/server/src/services/images.ts index 3de072b1..67b884c2 100644 --- a/packages/server/src/services/images.ts +++ b/packages/server/src/services/images.ts @@ -7,23 +7,23 @@ * and MIME inference. * * Exports: - * - normalizeImageList(projectRoot: string, value: unknown): { ok: true; data: string[] } | { ok: false; error: string } + * - normalizeImageList(projectRoot: string, value: unknown): Promise<{ ok: true; data: string[] } | { ok: false; error: string }> */ -import * as fs from "node:fs"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeProjectFilePath } from "./open-file"; const MAX_IMAGE_BYTES = 10 * 1024 * 1024; -export function normalizeImageList( +export async function normalizeImageList( projectRoot: string, value: unknown -): { ok: true; data: string[] } | { ok: false; error: string } { +): Promise<{ ok: true; data: string[] } | { ok: false; error: string }> { const items = Array.isArray(value) ? value : value === undefined ? [] : [value]; const imageUrls: string[] = []; for (const item of items) { - const normalized = normalizeImageItem(projectRoot, item); + const normalized = await normalizeImageItem(projectRoot, item); if (!normalized.ok) { return normalized; } @@ -34,10 +34,10 @@ export function normalizeImageList( return { ok: true, data: imageUrls }; } -function normalizeImageItem( +async function normalizeImageItem( projectRoot: string, item: unknown -): { ok: true; data: string | null } | { ok: false; error: string } { +): Promise<{ ok: true; data: string | null } | { ok: false; error: string }> { if (typeof item === "string") { return normalizeImageString(projectRoot, item); } @@ -59,10 +59,10 @@ function normalizeImageItem( return { ok: false, error: "Image object requires dataUrl, url, filePath, or path" }; } -function normalizeImageString( +async function normalizeImageString( projectRoot: string, value: string -): { ok: true; data: string | null } | { ok: false; error: string } { +): Promise<{ ok: true; data: string | null } | { ok: false; error: string }> { const trimmed = value.trim(); if (!trimmed) { return { ok: true, data: null }; @@ -86,17 +86,17 @@ function normalizeImageString( return readImageFileAsDataUrl(projectRoot, trimmed); } -function readImageFileAsDataUrl( +async function readImageFileAsDataUrl( projectRoot: string, filePath: string -): { ok: true; data: string } | { ok: false; error: string } { +): Promise<{ ok: true; data: string } | { ok: false; error: string }> { const request = normalizeProjectFilePath(projectRoot, filePath); if (!request.ok) { return request; } - let stat: fs.Stats; + let stat; try { - stat = fs.statSync(request.data.absolutePath); + stat = await fs.stat(request.data.absolutePath); } catch { return { ok: false, error: `Image file not found: ${request.data.relativePath}` }; } @@ -110,7 +110,8 @@ function readImageFileAsDataUrl( if (!mime) { return { ok: false, error: `Unsupported image type: ${request.data.relativePath}` }; } - return { ok: true, data: `data:${mime};base64,${fs.readFileSync(request.data.absolutePath).toString("base64")}` }; + const content = await fs.readFile(request.data.absolutePath); + return { ok: true, data: `data:${mime};base64,${content.toString("base64")}` }; } function getImageMimeType(filePath: string): string | null { From 5a4e6e2a3d4372cd6bf63cc7bcc00c95eb6ae9c6 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 22:47:47 +0800 Subject: [PATCH 13/43] Make server prompt content image normalization async --- packages/server/src/services/prompt-content.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/server/src/services/prompt-content.ts b/packages/server/src/services/prompt-content.ts index b82d21e1..2dde3e87 100644 --- a/packages/server/src/services/prompt-content.ts +++ b/packages/server/src/services/prompt-content.ts @@ -13,12 +13,12 @@ import { normalizeImageList } from "./images"; import { normalizePermissionScopes, normalizeUserPermissions } from "./permissions"; import type { RequestBody } from "./request-body"; -export function buildPromptContent( +export async function buildPromptContent( projectRoot: string, body: RequestBody -): { ok: true; data: UserPromptContent } | { ok: false; error: string } { +): Promise<{ ok: true; data: UserPromptContent } | { ok: false; error: string }> { const text = typeof body.text === "string" ? body.text : typeof body.prompt === "string" ? body.prompt : ""; - const images = normalizeImageList(projectRoot, body.images ?? body.imageUrls); + const images = await normalizeImageList(projectRoot, body.images ?? body.imageUrls); if (!images.ok) { return images; } From b4db5a9bc36a4ff8c12ddb99e7eac8571edcb440 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 22:48:25 +0800 Subject: [PATCH 14/43] Await async prompt content normalization in routes --- packages/server/src/services/routes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/routes.ts b/packages/server/src/services/routes.ts index 614d621e..05d03505 100644 --- a/packages/server/src/services/routes.ts +++ b/packages/server/src/services/routes.ts @@ -84,7 +84,7 @@ export async function routeRequest(input: RouteRequestInput): Promise { return sendJson(response, 200, await runtime.selectSession(String(body.sessionId ?? ""))); } if (method === "POST" && pathname === "/prompt") { - const prompt = buildPromptContent(projectRoot, await readJsonBody(request)); + const prompt = await buildPromptContent(projectRoot, await readJsonBody(request)); return sendJson( response, prompt.ok ? 202 : 400, @@ -152,7 +152,7 @@ export async function routeRequest(input: RouteRequestInput): Promise { } const body = method === "POST" ? await readJsonBody(request) : {}; - const prompt = buildPromptContent(projectRoot, { ...body, text: `/${command.name}` }); + const prompt = await buildPromptContent(projectRoot, { ...body, text: `/${command.name}` }); return sendJson( response, prompt.ok ? 202 : 400, From 26f6470635ece14ec810e9e0519890600012b739 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 22:49:19 +0800 Subject: [PATCH 15/43] Add server help version and project root options --- packages/server/src/server.ts | 43 ++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 3a567e93..cec614b0 100755 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,7 +1,9 @@ #!/usr/bin/env node +import * as path from "node:path"; import { createRequire } from "node:module"; import { setShellIfWindows } from "@vegamo/deepcode-core"; import { runHeadlessHttp } from "./index"; +import { readArgValue } from "./services/server-options"; const require = createRequire(import.meta.url); @@ -10,15 +12,23 @@ type PackageInfo = { }; const args = process.argv.slice(2); -const projectRoot = process.cwd(); +const version = readVersion(); + +if (args.includes("--version") || args.includes("-v")) { + process.stdout.write(`${version}\n`); + process.exit(0); +} + +if (args.includes("--help") || args.includes("-h")) { + process.stdout.write(buildHelpText()); + process.exit(0); +} + +const projectRoot = path.resolve(readArgValue(args, "--project-root") ?? process.cwd()); configureWindowsShell(); try { - await runHeadlessHttp({ - args, - projectRoot, - version: readVersion(), - }); + await runHeadlessHttp({ args, projectRoot, version }); } catch (error) { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`deepcode server failed: ${message}\n`); @@ -44,3 +54,24 @@ function readVersion(): string { return "unknown"; } } + +function buildHelpText(): string { + return [ + "deepcode-server - Deep Code local HTTP/SSE runtime server", + "", + "Usage:", + " deepcode-server [--host ] [--port ] [--project-root ]", + " deepcode-server --version", + " deepcode-server --help", + "", + "Options:", + " --host Bind address. Defaults to 127.0.0.1.", + " --port Bind port. Defaults to 8787.", + " --project-root Deep Code project root. Defaults to current working directory.", + " --no-auth Turn off local token auth for trusted local development.", + " --unsafe-bind Allow non-local bind addresses.", + " --version, -v Print the version.", + " --help, -h Show this help.", + "", + ].join("\n"); +} From 5efe0f879505a99fcefba296f5e31c29167cf120 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 22:49:29 +0800 Subject: [PATCH 16/43] Add server test script --- packages/server/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/package.json b/packages/server/package.json index 3298dddc..0e116a3c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -26,6 +26,7 @@ "scripts": { "typecheck": "tsc -p ./ --noEmit", "build": "tsc -p ./ && node ../../scripts/rewrite-esm-imports.js server && node -e \"require('fs').chmodSync('dist/server.js', 0o755)\"", + "test": "tsx --test src/tests/*.test.ts", "prepublishOnly": "npm run build", "format": "prettier --write ." }, From d87ae2889c357eed2fe15169b4c35d71eaa052b1 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 22:49:47 +0800 Subject: [PATCH 17/43] Add server auth tests --- packages/server/src/tests/auth.test.ts | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 packages/server/src/tests/auth.test.ts diff --git a/packages/server/src/tests/auth.test.ts b/packages/server/src/tests/auth.test.ts new file mode 100644 index 00000000..46055cec --- /dev/null +++ b/packages/server/src/tests/auth.test.ts @@ -0,0 +1,28 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { IncomingMessage } from "node:http"; +import { isAuthorized } from "../services/auth"; + +function request(url: string, headers: IncomingMessage["headers"] = {}): IncomingMessage { + return { url, headers } as IncomingMessage; +} + +test("isAuthorized accepts query token", () => { + assert.equal(isAuthorized(request("/health?token=secret"), "secret"), true); +}); + +test("isAuthorized accepts x-deepcode-token header values", () => { + assert.equal(isAuthorized(request("/health", { "x-deepcode-token": "secret" }), "secret"), true); + assert.equal(isAuthorized(request("/health", { "x-deepcode-token": ["secret", "other"] }), "secret"), true); +}); + +test("isAuthorized accepts bearer authorization case-insensitively", () => { + assert.equal(isAuthorized(request("/health", { authorization: "Bearer secret" }), "secret"), true); + assert.equal(isAuthorized(request("/health", { authorization: "bearer secret" }), "secret"), true); +}); + +test("isAuthorized rejects missing or mismatched tokens", () => { + assert.equal(isAuthorized(request("/health"), "secret"), false); + assert.equal(isAuthorized(request("/health?token=wrong"), "secret"), false); + assert.equal(isAuthorized(request("/health", { authorization: "Bearer wrong" }), "secret"), false); +}); From 46adcdca92fe09fdbc10072258edd2c9637a8250 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 22:49:58 +0800 Subject: [PATCH 18/43] Add server option tests --- .../server/src/tests/server-options.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 packages/server/src/tests/server-options.test.ts diff --git a/packages/server/src/tests/server-options.test.ts b/packages/server/src/tests/server-options.test.ts new file mode 100644 index 00000000..09596ee1 --- /dev/null +++ b/packages/server/src/tests/server-options.test.ts @@ -0,0 +1,22 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseServerOptions, readArgValue } from "../services/server-options"; + +test("parseServerOptions uses local defaults", () => { + assert.deepEqual(parseServerOptions([]), { host: "127.0.0.1", port: 8787, authDisabled: false }); +}); + +test("parseServerOptions accepts inline and separated option values", () => { + assert.deepEqual(parseServerOptions(["--host=localhost", "--port", "9000", "--no-auth"]), { + host: "localhost", + port: 9000, + authDisabled: true, + }); + assert.equal(readArgValue(["--project-root", "/tmp/work"], "--project-root"), "/tmp/work"); +}); + +test("parseServerOptions rejects invalid ports and unsafe binds", () => { + assert.throws(() => parseServerOptions(["--port", "0"]), /Invalid --port value/u); + assert.throws(() => parseServerOptions(["--host", "0.0.0.0"]), /requires --unsafe-bind/u); + assert.equal(parseServerOptions(["--host", "0.0.0.0", "--unsafe-bind"]).host, "0.0.0.0"); +}); From e5dae44ee5c7ce3c70cb7cf6413b2006471585e8 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 22:50:09 +0800 Subject: [PATCH 19/43] Add server image normalization tests --- packages/server/src/tests/images.test.ts | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/server/src/tests/images.test.ts diff --git a/packages/server/src/tests/images.test.ts b/packages/server/src/tests/images.test.ts new file mode 100644 index 00000000..38515df2 --- /dev/null +++ b/packages/server/src/tests/images.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { normalizeImageList } from "../services/images"; + +test("normalizeImageList keeps remote and data image URLs", async () => { + const result = await normalizeImageList(process.cwd(), [ + "https://example.com/image.png", + "data:image/png;base64,AAAA", + ]); + assert.deepEqual(result, { ok: true, data: ["https://example.com/image.png", "data:image/png;base64,AAAA"] }); +}); + +test("normalizeImageList rejects blob URLs", async () => { + const result = await normalizeImageList(process.cwd(), "blob:https://example.com/image"); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /blob image URLs/u); + } +}); + +test("normalizeImageList reads project-local files asynchronously", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "deepcode-server-images-")); + try { + await fs.writeFile(path.join(root, "pixel.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const result = await normalizeImageList(root, { path: "pixel.png" }); + assert.equal(result.ok, true); + if (result.ok) { + assert.match(result.data[0] ?? "", /^data:image\/png;base64,/u); + } + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("normalizeImageList rejects paths outside project root", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "deepcode-server-images-")); + try { + const result = await normalizeImageList(root, "../outside.png"); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /inside the project root/u); + } + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); From 45638ce0755ea98b7bff6c6fa514a4f4d67034fb Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 22:50:47 +0800 Subject: [PATCH 20/43] Clarify server JSON body size limit --- packages/server/src/services/request-body.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/request-body.ts b/packages/server/src/services/request-body.ts index 8fc0ffd3..533d7182 100644 --- a/packages/server/src/services/request-body.ts +++ b/packages/server/src/services/request-body.ts @@ -36,7 +36,8 @@ export type RequestBody = { deltaMs?: unknown; }; -const MAX_BODY_BYTES = 2 * 1024 * 1024; +const MAX_BODY_BYTES = 16 * 1024 * 1024; +const MAX_BODY_MIB = MAX_BODY_BYTES / 1024 / 1024; export async function readJsonBody(request: IncomingMessage): Promise { const chunks: Buffer[] = []; @@ -45,7 +46,7 @@ export async function readJsonBody(request: IncomingMessage): Promise MAX_BODY_BYTES) { - throw Object.assign(new Error("Request body too large"), { statusCode: 413 }); + throw Object.assign(new Error(`Request body too large; limit is ${MAX_BODY_MIB} MiB`), { statusCode: 413 }); } chunks.push(buffer); } From b2b4b814a3b4d15b6e84cc6152fda7073b95a6a1 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 22:51:25 +0800 Subject: [PATCH 21/43] Add server request body tests --- .../server/src/tests/request-body.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 packages/server/src/tests/request-body.test.ts diff --git a/packages/server/src/tests/request-body.test.ts b/packages/server/src/tests/request-body.test.ts new file mode 100644 index 00000000..b3e85604 --- /dev/null +++ b/packages/server/src/tests/request-body.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import type { IncomingMessage } from "node:http"; +import { readJsonBody } from "../services/request-body"; + +function requestBody(content?: string): IncomingMessage { + return Readable.from(content === undefined ? [] : [Buffer.from(content)]) as IncomingMessage; +} + +test("readJsonBody returns empty object for empty bodies", async () => { + assert.deepEqual(await readJsonBody(requestBody()), {}); + assert.deepEqual(await readJsonBody(requestBody(" ")), {}); +}); + +test("readJsonBody parses JSON bodies", async () => { + assert.deepEqual(await readJsonBody(requestBody('{"text":"hello"}')), { text: "hello" }); +}); + +test("readJsonBody rejects invalid JSON with statusCode 400", async () => { + await assert.rejects(readJsonBody(requestBody("{")), (error) => { + assert.equal((error as { statusCode?: number }).statusCode, 400); + return true; + }); +}); + +test("readJsonBody rejects bodies over the limit with statusCode 413", async () => { + const tooLarge = "x".repeat(16 * 1024 * 1024 + 1); + await assert.rejects(readJsonBody(requestBody(tooLarge)), (error) => { + assert.equal((error as { statusCode?: number }).statusCode, 413); + assert.match(String((error as Error).message), /16 MiB/u); + return true; + }); +}); From 4aa8d11ffcf7c871dc3ffa3cf18e78a00069c2b8 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 23:38:20 +0800 Subject: [PATCH 22/43] Restore core ESM import rewrite script --- scripts/rewrite-esm-imports.js | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/scripts/rewrite-esm-imports.js b/scripts/rewrite-esm-imports.js index 10c11c11..715e5c3b 100644 --- a/scripts/rewrite-esm-imports.js +++ b/scripts/rewrite-esm-imports.js @@ -1,37 +1,23 @@ /** - * Post-build script: rewrites extensionless relative imports in a built package's - * dist/ output to include explicit ".js" extensions. + * Post-build script: rewrites extensionless relative imports in the core + * package's dist/ output to include explicit ".js" extensions. * - * tsc with moduleResolution:"bundler" emits `from "./foo"` without an extension. + * tsc with moduleResolution:"bundler" emits `from "./foo"` (no extension). * Node.js ESM requires `from "./foo.js"`. This script bridges the gap. - * - * Usage: - * - node scripts/rewrite-esm-imports.js rewrites packages/core/dist - * - node scripts/rewrite-esm-imports.js server rewrites packages/server/dist */ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { globSync } from "glob"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, ".."); -const packageName = process.argv[2] ?? "core"; -const allowedPackages = new Set(["core", "server"]); - -if (!allowedPackages.has(packageName)) { - throw new Error(`Unsupported package for ESM import rewrite: ${packageName}`); -} - -const distDir = join(root, "packages", packageName, "dist"); -if (!existsSync(distDir)) { - throw new Error(`Cannot rewrite ESM imports because dist directory does not exist: ${distDir}`); -} +const distDir = join(root, "packages", "core", "dist"); const files = globSync("**/*.js", { cwd: distDir, absolute: true }); -// Match: from "./anything" or from "../anything". -// The negative lookahead skips specifiers that already end in common explicit extensions. +// Match: from "./anything" or from "../anything" +// Negative lookahead: skip if already ends with .js, .json, .node, or is a bare specifier const IMPORT_RE = /(from\s+["'])(\.\.?\/[^"']+?)(? Date: Wed, 24 Jun 2026 23:38:42 +0800 Subject: [PATCH 23/43] Add package-local server ESM import rewrite script --- .../server/scripts/rewrite-esm-imports.js | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 packages/server/scripts/rewrite-esm-imports.js diff --git a/packages/server/scripts/rewrite-esm-imports.js b/packages/server/scripts/rewrite-esm-imports.js new file mode 100644 index 00000000..5cb9f6bd --- /dev/null +++ b/packages/server/scripts/rewrite-esm-imports.js @@ -0,0 +1,44 @@ +/** + * Post-build script: rewrites extensionless relative imports in the server + * package's dist/ output to include explicit ".js" extensions. + * + * Keep this package-local so the server feature does not change the existing + * root-level core rewrite script. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "glob"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageRoot = join(__dirname, ".."); +const distDir = join(packageRoot, "dist"); + +if (!existsSync(distDir)) { + throw new Error(`Cannot rewrite server ESM imports because dist directory does not exist: ${distDir}`); +} + +const files = globSync("**/*.js", { cwd: distDir, absolute: true }); + +// Match: from "./anything" or from "../anything" +// Negative lookahead: skip if already ends with .js, .json, .node, or is a bare specifier +const IMPORT_RE = /(from\s+["'])(\.\.?\/[^"']+?)(? { + rewrites++; + return `${prefix}${specifier}.js${quote}`; + }); + + if (rewrites > 0) { + writeFileSync(filePath, updated, "utf8"); + totalRewrites += rewrites; + } +} + +console.log(`\n✅ Rewrote ${totalRewrites} imports across ${files.length} files in server/dist/\n`); From 3dcfdebd7b5a4f4764ad0ee301983daa95d459d2 Mon Sep 17 00:00:00 2001 From: Ri0n72Y Date: Wed, 24 Jun 2026 23:38:52 +0800 Subject: [PATCH 24/43] Use package-local server ESM rewrite script --- packages/server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/package.json b/packages/server/package.json index 0e116a3c..41c2511a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -25,7 +25,7 @@ }, "scripts": { "typecheck": "tsc -p ./ --noEmit", - "build": "tsc -p ./ && node ../../scripts/rewrite-esm-imports.js server && node -e \"require('fs').chmodSync('dist/server.js', 0o755)\"", + "build": "tsc -p ./ && node scripts/rewrite-esm-imports.js && node -e \"require('fs').chmodSync('dist/server.js', 0o755)\"", "test": "tsx --test src/tests/*.test.ts", "prepublishOnly": "npm run build", "format": "prettier --write ." From 346ecee0c988eb6e62d7e4aadf7261d03453a4f5 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 09:00:13 +0800 Subject: [PATCH 25/43] =?UTF-8?q?refactor(cli):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=A1=8C=E5=8F=82=E6=95=B0=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E5=90=AF=E5=8A=A8=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 yargs 库替代原有手写解析,增强参数解析的健壮性和可维护性 - 添加严格参数校验,提升错误提示的清晰度 - 统一处理 --resume 和 --prompt 参数的组合逻辑,避免冲突使用 - 改进启动流程,确保先恢复会话再提交初始提示 - 将 resetStaticView 方法修改为异步以支持启动流程等待 - 替换部分异步调用为 await 确保顺序执行,避免竞态问题 - 更新 CLI 帮助文案,优化用户体验和信息表达 - 调整欢迎界面文本样式,增加标题加粗显示 - 添加相关单元测试 covering 新的参数解析和校验逻辑 - 引入 yargs 及其类型依赖,更新 package.json 和锁文件依赖清单 --- package-lock.json | 146 +++++++++++++++- packages/cli/package.json | 6 +- packages/cli/src/cli-args.ts | 111 ++++++++++--- packages/cli/src/cli.tsx | 124 +++++++------- packages/cli/src/tests/cli-args.test.ts | 174 +++++++++++++++----- packages/cli/src/tests/exit-summary.test.ts | 2 +- packages/cli/src/ui/views/App.tsx | 72 ++++---- packages/cli/src/ui/views/WelcomeScreen.tsx | 4 +- 8 files changed, 487 insertions(+), 152 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e4b47f0..954b83b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1647,6 +1647,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", @@ -2724,6 +2741,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/cockatiel": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", @@ -3109,7 +3186,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, "license": "MIT" }, "node_modules/encoding-sniffer": { @@ -3266,7 +3342,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3753,6 +3828,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -7385,6 +7469,15 @@ "node": ">=4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -7409,6 +7502,49 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yauzl": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", @@ -7484,11 +7620,15 @@ "ignore": "^7.0.5", "ink": "^7.0.4", "ink-gradient": "^4.0.1", - "react": "^19.2.5" + "react": "^19.2.5", + "yargs": "^18.0.0" }, "bin": { "deepcode": "dist/cli.js" }, + "devDependencies": { + "@types/yargs": "^17.0.35" + }, "engines": { "node": ">=22" } diff --git a/packages/cli/package.json b/packages/cli/package.json index 654038ee..977bad59 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,10 @@ "ignore": "^7.0.5", "ink": "^7.0.4", "ink-gradient": "^4.0.1", - "react": "^19.2.5" + "react": "^19.2.5", + "yargs": "^18.0.0" + }, + "devDependencies": { + "@types/yargs": "^17.0.35" } } diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts index 5ae0325e..3851ac00 100644 --- a/packages/cli/src/cli-args.ts +++ b/packages/cli/src/cli-args.ts @@ -1,31 +1,102 @@ /** * CLI argument parsing helpers. - * Extracted from cli.tsx for testability. + * Uses yargs for robust argument parsing and validation. */ -export function extractInitialPrompt(args: string[]): string | undefined { - const promptIndex = args.findIndex((arg) => arg === "-p" || arg === "--prompt"); - if (promptIndex !== -1 && promptIndex + 1 < args.length) { - return args[promptIndex + 1]; - } - return undefined; +import Yargs from "yargs"; + +export interface ParsedCliArgs { + /** Prompt text from -p / --prompt */ + prompt: string | undefined; + /** + * Resume session identifier: + * - `undefined` — --resume was not used + * - `true` — --resume was used without a session ID (show picker) + * - `string` — --resume was used + */ + resume: string | true | undefined; + /** True when --version / -v was passed */ + version: boolean; + /** True when --help / -h was passed */ + help: boolean; +} + +export interface CliParseError { + message: string; } /** - * Extract the --resume flag value. - * - * Returns: - * - `undefined` — `--resume` was not used - * - `true` — `--resume` was used without a session ID (show session picker) - * - `string` — `--resume ` was used (resume specific session) + * Parse CLI arguments with validation. + * Returns parsed args on success, or an error object if the arguments are invalid. */ -export function extractResumeSessionId(args: string[]): string | true | undefined { - const idx = args.findIndex((arg) => arg === "--resume"); - if (idx === -1) { - return undefined; +export function parseCliArgs(argv: string[]): ParsedCliArgs | CliParseError { + let validationError: string | null = null; + + const y = Yargs(argv) + .locale("en") + .scriptName("deepcode") + .version(false) + .help(false) + .option("version", { + alias: "v", + type: "boolean", + describe: "Print the version", + }) + .option("help", { + alias: "h", + type: "boolean", + describe: "Show this help", + }) + .option("resume", { + alias: "r", + type: "string", + describe: "Resume a specific session by its ID. Use without an ID to show session picker.", + }) + .option("prompt", { + alias: "p", + type: "string", + describe: "Submit a prompt on launch", + }) + .strict() + .exitProcess(false) + .fail((msg) => { + validationError = msg; + }) + .check((parsed) => { + // bare --resume conflicts with --prompt + if (parsed.resume === "" && parsed.prompt) { + throw new Error( + "Cannot use --resume without a session ID together with --prompt.\n" + + "Use --resume -p to resume a session and send a prompt." + ); + } + // empty prompt is meaningless + if (parsed.prompt === "") { + throw new Error("--prompt / -p requires a non-empty value."); + } + return true; + }); + + const parsed = y.parseSync() as Record; + + if (validationError) { + return { message: validationError }; } - if (idx + 1 < args.length && !args[idx + 1].startsWith("-")) { - return args[idx + 1]; + + const resumeRaw = parsed.resume as string | undefined; + let resume: ParsedCliArgs["resume"]; + if (resumeRaw === undefined) { + resume = undefined; + } else if (resumeRaw === "") { + resume = true; + } else { + resume = resumeRaw; } - return true; + + return { + prompt: parsed.prompt as string | undefined, + resume, + version: parsed.version === true, + help: parsed.help === true, + }; } diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 48152d0b..1ea702bc 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -6,69 +6,86 @@ import { homedir } from "node:os"; import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; import { AppContainer } from "./ui"; -import { extractInitialPrompt, extractResumeSessionId } from "./cli-args"; +import { hideBin } from "yargs/helpers"; +import { parseCliArgs } from "./cli-args"; import { CLI_VERSION, GIT_COMMIT_INFO } from "./generated/git-commit"; -const args = process.argv.slice(2); +const args = hideBin(process.argv); const packageInfo = readPackageInfo(); -if (args.includes("--version") || args.includes("-v")) { +const HELP_TEXT = + [ + "", + "Usage: deepcode [options] [command]\n\nDeep Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode", + "", + "Commands:", + " deepcode Launch the interactive TUI in the current directory", + "", + "Options:", + " -p, --prompt Launch with a pre-filled prompt", + " -r, --resume [sessionId] Resume a specific session by its ID. Use without an ID to show session picker", + " -v, --version Show version number", + " -h, --help Show help", + "", + "Configuration:", + " ~/.deepcode/settings.json User-level API key, model, base URL", + " ./.deepcode/settings.json Project-level settings", + " ./.deepcode/skills/*/SKILL.md Project-level native skills", + " ./.agents/skills/*/SKILL.md Project-level interoperable skills", + " ~/.deepcode/skills/*/SKILL.md User-level native skills", + " ~/.agents/skills/*/SKILL.md User-level interoperable skills", + "", + "Inside the TUI:", + " enter Send the prompt", + " shift+enter Insert a newline", + " home/end Move within the current line", + " alt+left/right Move by word", + " ctrl+w Delete the previous word", + " ctrl+v Paste an image from the clipboard", + " ctrl+x Clear pasted images", + " esc Interrupt the current model turn", + " / Open the skills/commands menu", + " /skills List available skills", + " /model Select model, thinking mode and effort control", + " /new Start a fresh conversation", + " /init Initialize an AGENTS.md file with instructions for LLM", + " /resume Pick a previous conversation to continue", + " /continue Continue the active conversation, or resume one if empty", + " /undo Restore code and/or conversation to a previous point", + " /mcp Show MCP server status and available tools", + " /raw Toggle display mode for viewing or collapsing reasoning content", + " /exit Quit", + " ctrl+d twice Quit", + ].join("\n") + "\n"; + +const parsed = parseCliArgs(args); + +if ("message" in parsed) { + process.stderr.write(parsed.message + "\n\n"); + process.stdout.write(HELP_TEXT); + process.exit(1); +} + +if (parsed.version) { process.stdout.write(`${packageInfo.version || "unknown"}\n`); process.exit(0); } -if (args.includes("--help") || args.includes("-h")) { - process.stdout.write( - [ - "deepcode - Deep Code CLI", - "", - "Usage:", - " deepcode Launch the interactive TUI in the current directory", - " deepcode -p Launch with a pre-filled prompt", - " deepcode --prompt Same as -p", - " deepcode --resume [sessionId] Resume a specific session by its ID. Use without an ID to show session picker", - " deepcode --version Print the version", - " deepcode --help Show this help", - "", - "Configuration:", - " ~/.deepcode/settings.json User-level API key, model, base URL", - " ./.deepcode/settings.json Project-level settings", - " ./.deepcode/skills/*/SKILL.md Project-level native skills", - " ./.agents/skills/*/SKILL.md Project-level interoperable skills", - " ~/.deepcode/skills/*/SKILL.md User-level native skills", - " ~/.agents/skills/*/SKILL.md User-level interoperable skills", - "", - "Inside the TUI:", - " enter Send the prompt", - " shift+enter Insert a newline", - " home/end Move within the current line", - " alt+left/right Move by word", - " ctrl+w Delete the previous word", - " ctrl+v Paste an image from the clipboard", - " ctrl+x Clear pasted images", - " esc Interrupt the current model turn", - " / Open the skills/commands menu", - " /skills List available skills", - " /model Select model, thinking mode and effort control", - " /new Start a fresh conversation", - " /init Initialize an AGENTS.md file with instructions for LLM", - " /resume Pick a previous conversation to continue", - " /continue Continue the active conversation, or resume one if empty", - " /undo Restore code and/or conversation to a previous point", - " /mcp Show MCP server status and available tools", - " /raw Toggle display mode for viewing or collapsing reasoning content", - " /exit Quit", - " ctrl+d twice Quit", - ].join("\n") + "\n" - ); +if (parsed.help) { + process.stdout.write(HELP_TEXT); process.exit(0); } -let initialPrompt = extractInitialPrompt(args); -const resumeSessionId = extractResumeSessionId(args); +let initialPrompt = parsed.prompt; +let resumeSessionId = parsed.resume; const projectRoot = process.cwd(); configureWindowsShell(); +if (!process.stdin.isTTY) { + process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); + process.exit(1); +} + // Validate --resume before entering TUI if (typeof resumeSessionId === "string") { const projectCode = getProjectCode(projectRoot); @@ -86,11 +103,6 @@ if (typeof resumeSessionId === "string") { } } -if (!process.stdin.isTTY) { - process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); - process.exit(1); -} - void main(); async function main(): Promise { @@ -105,12 +117,14 @@ async function main(): Promise { let restarting = false; const appInitialPrompt = initialPrompt; initialPrompt = undefined; + const appResumeSessionId = resumeSessionId; + resumeSessionId = undefined; const inkInstance = render( restartRef.current?.()} />, { exitOnCtrlC: false } diff --git a/packages/cli/src/tests/cli-args.test.ts b/packages/cli/src/tests/cli-args.test.ts index e42a4b00..fd97de76 100644 --- a/packages/cli/src/tests/cli-args.test.ts +++ b/packages/cli/src/tests/cli-args.test.ts @@ -1,76 +1,168 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { extractInitialPrompt, extractResumeSessionId } from "../cli-args"; +import { parseCliArgs } from "../cli-args"; -// ── extractInitialPrompt ───────────────────────────────────────────────────── +// ── parseCliArgs: basic parsing ────────────────────────────────────────────── -test("extractInitialPrompt returns prompt after -p", () => { - assert.equal(extractInitialPrompt(["-p", "hello world"]), "hello world"); +test("parseCliArgs returns prompt after -p", () => { + const r = parseCliArgs(["-p", "hello world"]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, "hello world"); }); -test("extractInitialPrompt returns prompt after --prompt", () => { - assert.equal(extractInitialPrompt(["--prompt", "hello world"]), "hello world"); +test("parseCliArgs returns prompt after --prompt", () => { + const r = parseCliArgs(["--prompt", "hello world"]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, "hello world"); }); -test("extractInitialPrompt returns undefined when -p is not present", () => { - assert.equal(extractInitialPrompt(["--version"]), undefined); +test("parseCliArgs returns undefined prompt when -p is not present", () => { + const r = parseCliArgs(["--resume"]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, undefined); }); -test("extractInitialPrompt returns undefined when -p has no value", () => { - assert.equal(extractInitialPrompt(["-p"]), undefined); +test("parseCliArgs returns session ID after --resume", () => { + const r = parseCliArgs(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); }); -test("extractInitialPrompt returns undefined for empty args", () => { - assert.equal(extractInitialPrompt([]), undefined); +test("parseCliArgs returns true when --resume has no value", () => { + const r = parseCliArgs(["--resume"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, true); }); -test("extractInitialPrompt ignores -p in non-flag position", () => { - assert.equal(extractInitialPrompt(["--resume", "-p", "hello"]), "hello"); +test("parseCliArgs returns undefined resume when not present", () => { + const r = parseCliArgs(["-p", "test"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, undefined); }); -// ── extractResumeSessionId ─────────────────────────────────────────────────── +test("parseCliArgs returns defaults for empty args", () => { + const r = parseCliArgs([]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, undefined); + assert.equal(r.resume, undefined); + assert.equal(r.version, false); + assert.equal(r.help, false); +}); + +// ── parseCliArgs: -r alias ─────────────────────────────────────────────────── + +test("parseCliArgs returns session ID after -r", () => { + const r = parseCliArgs(["-r", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); +}); + +test("parseCliArgs returns true when -r has no value", () => { + const r = parseCliArgs(["-r"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, true); +}); + +test("parseCliArgs handles -r combined with -p", () => { + const r = parseCliArgs(["-r", "session-123", "-p", "hello"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "session-123"); + assert.equal(r.prompt, "hello"); +}); + +test("parseCliArgs rejects bare -r with -p", () => { + const r = parseCliArgs(["-r", "-p", "hello"]); + assert.ok("message" in r); + assert.match(r.message, /Cannot use --resume/); +}); -test("extractResumeSessionId returns session ID after --resume", () => { - assert.equal( - extractResumeSessionId(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]), - "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6" - ); +// ── parseCliArgs: --version / --help ───────────────────────────────────────── + +test("parseCliArgs detects --version", () => { + const r = parseCliArgs(["--version"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); + assert.equal(r.help, false); +}); + +test("parseCliArgs detects -v", () => { + const r = parseCliArgs(["-v"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); +}); + +test("parseCliArgs detects --help", () => { + const r = parseCliArgs(["--help"]); + assert.ok(!("message" in r)); + assert.equal(r.help, true); + assert.equal(r.version, false); }); -test("extractResumeSessionId returns true when --resume has no value (show picker)", () => { - assert.equal(extractResumeSessionId(["--resume"]), true); +test("parseCliArgs detects -h", () => { + const r = parseCliArgs(["-h"]); + assert.ok(!("message" in r)); + assert.equal(r.help, true); }); -test("extractResumeSessionId returns true when --resume is followed by another flag", () => { - assert.equal(extractResumeSessionId(["--resume", "--force"]), true); +test("parseCliArgs version and help are false when not passed", () => { + const r = parseCliArgs(["-p", "hello"]); + assert.ok(!("message" in r)); + assert.equal(r.version, false); + assert.equal(r.help, false); }); -test("extractResumeSessionId returns undefined when --resume is not present", () => { - assert.equal(extractResumeSessionId(["--version"]), undefined); +test("parseCliArgs handles -v combined with -r (both flags set)", () => { + const r = parseCliArgs(["-v", "-r", "abc"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); + assert.equal(r.resume, "abc"); }); -test("extractResumeSessionId returns undefined for empty args", () => { - assert.equal(extractResumeSessionId([]), undefined); +// ── parseCliArgs: combined usage ───────────────────────────────────────────── + +test("parseCliArgs handles --resume combined with -p", () => { + const r = parseCliArgs(["--resume", "session-123", "-p", "hello"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "session-123"); + assert.equal(r.prompt, "hello"); }); -test("extractResumeSessionId works with other flags after sessionId", () => { - assert.equal(extractResumeSessionId(["--resume", "abc-123", "--force"]), "abc-123"); +test("parseCliArgs handles -p before --resume ", () => { + const r = parseCliArgs(["-p", "hello", "--resume", "session-123"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "session-123"); + assert.equal(r.prompt, "hello"); }); -test("extractResumeSessionId does not confuse --resume with other args", () => { - assert.equal(extractResumeSessionId(["-p", "test"]), undefined); +// ── parseCliArgs: validation ───────────────────────────────────────────────── + +test("parseCliArgs rejects bare --resume with -p", () => { + const r = parseCliArgs(["--resume", "-p", "hello"]); + assert.ok("message" in r); + assert.match(r.message, /Cannot use --resume/); }); -// ── combined usage ─────────────────────────────────────────────────────────── +test("parseCliArgs rejects -p with bare --resume (reversed order)", () => { + const r = parseCliArgs(["-p", "hello", "--resume"]); + assert.ok("message" in r); + assert.match(r.message, /Cannot use --resume/); +}); + +test("parseCliArgs rejects unknown flags in strict mode", () => { + const r = parseCliArgs(["--unknown-flag"]); + assert.ok("message" in r); + assert.match(r.message, /Unknown argument/); +}); -test("extractInitialPrompt and extractResumeSessionId work independently", () => { - const args = ["--resume", "session-123", "-p", "hello"]; - assert.equal(extractResumeSessionId(args), "session-123"); - assert.equal(extractInitialPrompt(args), "hello"); +test("parseCliArgs rejects empty -p value", () => { + const r = parseCliArgs(["-p", ""]); + assert.ok("message" in r); + assert.match(r.message, /non-empty/); }); -test("extractResumeSessionId with --resume and -p but no sessionId", () => { - const args = ["--resume", "-p", "hello"]; - assert.equal(extractResumeSessionId(args), true); - assert.equal(extractInitialPrompt(args), "hello"); +test("parseCliArgs --version takes precedence over --help", () => { + const r = parseCliArgs(["--version", "--help"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); + assert.equal(r.help, true); }); diff --git a/packages/cli/src/tests/exit-summary.test.ts b/packages/cli/src/tests/exit-summary.test.ts index d768c165..fd6b8ad0 100644 --- a/packages/cli/src/tests/exit-summary.test.ts +++ b/packages/cli/src/tests/exit-summary.test.ts @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { buildExitSummaryText } from "../ui"; import type { ModelUsage, SessionEntry } from "@vegamo/deepcode-core"; -const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*m/g, ""); +const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, ""); test("buildExitSummaryText only shows Goodbye and model usage with cached tokens", () => { const summary = stripAnsi( diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 4175b49d..5e345802 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -97,6 +97,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const { mode, setMode } = useRawModeContext(); const initialPromptSubmittedRef = useRef(false); const resumeSessionIdRef = useRef(false); + const startupDoneRef = useRef(false); const processStdoutRef = useRef>(new Map()); const rawModeRef = useRef(mode); const writeRef = useRef(write); @@ -190,17 +191,20 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp * Reset the static view to the welcome screen. */ const resetStaticView = useCallback( - (loadedMessages: SessionMessage[], options?: { clearScreen?: boolean }) => { + (loadedMessages: SessionMessage[], options?: { clearScreen?: boolean }): Promise => { if (options?.clearScreen) { process.stdout.write(ANSI_CLEAR_SCREEN); } setMessages([]); setWelcomeNonce((n) => n + 1); navigateToSubView("chat"); - setTimeout(() => { - setMessages(loadedMessages); - setShowWelcome(true); - }, 0); + return new Promise((resolve) => { + setTimeout(() => { + setMessages(loadedMessages); + setShowWelcome(true); + resolve(); + }, 0); + }); }, [navigateToSubView] ); @@ -246,7 +250,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setActiveAskPermissions(undefined); setPendingPermissionReply(null); setDismissedQuestionIds(new Set()); - resetStaticView([]); + await resetStaticView([]); await refreshSkills(); }, [sessionManager, resetStaticView, refreshSkills]); @@ -477,24 +481,11 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp [resetStaticView, sessionManager] ); - useEffect(() => { - if (initialPromptSubmittedRef.current || !initialPrompt || !initialPrompt.trim()) { - return; - } - - initialPromptSubmittedRef.current = true; - handleSubmit({ - text: initialPrompt, - imageUrls: [], - selectedSkills: undefined, - }); - }, [handleSubmit, initialPrompt]); - const handleSelectSession = useCallback( async (sessionId: string) => { sessionManager.setActiveSessionId(sessionId); // Clear first so resets its index to 0. - resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); + await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); const session = sessionManager.getSession(sessionId); setStatusLine(session ? buildStatusLine(session) : ""); setRunningProcesses(session?.processes ?? null); @@ -508,21 +499,42 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp [sessionManager, resetStaticView, pendingPermissionReply, refreshSkills] ); + /** + * Coordinated startup effect: handle --resume and --prompt together. + * When both are present, resume the session first, then submit the prompt. + */ useEffect(() => { - if (resumeSessionIdRef.current || !resumeSessionId) { + if (startupDoneRef.current) { return; } + startupDoneRef.current = true; + + async function run() { + // Step 1: Resume session if requested + if (resumeSessionId) { + resumeSessionIdRef.current = true; + if (resumeSessionId === true) { + // Bare --resume — show session picker; prompt makes no sense here + refreshSessionsList(); + navigateToSubView("session-list"); + return; + } + await handleSelectSession(resumeSessionId); + } - resumeSessionIdRef.current = true; - if (resumeSessionId === true) { - // No session ID — show the session picker (same as /resume) - refreshSessionsList(); - navigateToSubView("session-list"); - } else { - // Session ID already validated in cli.tsx — guaranteed to exist - handleSelectSession(resumeSessionId); + // Step 2: Submit prompt if provided + if (initialPrompt && initialPrompt.trim()) { + initialPromptSubmittedRef.current = true; + handleSubmit({ + text: initialPrompt, + imageUrls: [], + selectedSkills: undefined, + }); + } } - }, [handleSelectSession, navigateToSubView, refreshSessionsList, resumeSessionId]); + + void run(); + }, [handleSubmit, handleSelectSession, initialPrompt, navigateToSubView, refreshSessionsList, resumeSessionId]); const handleDeleteSession = useCallback( async (id: string): Promise => { diff --git a/packages/cli/src/ui/views/WelcomeScreen.tsx b/packages/cli/src/ui/views/WelcomeScreen.tsx index fdcf9211..e465a2f6 100644 --- a/packages/cli/src/ui/views/WelcomeScreen.tsx +++ b/packages/cli/src/ui/views/WelcomeScreen.tsx @@ -58,7 +58,9 @@ export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeS paddingX={1} > - {">"}_ Deep Code + + {">"}_ Deep Code{" "} + (v{version || "unknown"}) {!compact ? : null} From 24a2ad934e2d59caf74a5a0515432603d618dc04 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 14:37:41 +0800 Subject: [PATCH 26/43] =?UTF-8?q?refactor(cli):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=A1=8C=E5=8F=82=E6=95=B0=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=92=8C=E4=B8=BB=E5=85=A5=E5=8F=A3=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用异步方式获取包信息,替代同步读取package.json - 用yargs重构参数解析,加入严格校验和格式验证 - 新增UUID格式验证函数,确保会话ID合法性 - 改善错误输出,统一通过writeStderrLine打印错误信息 - 移除过时的手工参数解析逻辑,改用parseArguments异步解析 - 统一并简化应用启动流程,支持终端交互性检查 - 替换process.stdout.write为writeStdoutLine,增强代码一致性 - 增加对版本号、帮助信息参数的自动处理和退出 - 使用read-package-up获取package.json,保证包信息准确 - 测试覆盖parseArguments及isValidSessionId的多种场景和错误处理 - 修改构建脚本为异步导入fs模块的chmodSync操作,兼容现代Node版本 --- package-lock.json | 121 ++++++++++++-- packages/cli/package.json | 3 +- packages/cli/src/cli-args.ts | 166 +++++++++++++------- packages/cli/src/cli.tsx | 146 +++++------------ packages/cli/src/common/update-check.ts | 9 +- packages/cli/src/tests/cli-args.test.ts | 199 ++++++++++++++---------- packages/cli/src/utils/package.ts | 29 ++++ packages/cli/src/utils/stdioHelpers.ts | 25 +++ packages/cli/src/utils/version.ts | 6 + 9 files changed, 447 insertions(+), 257 deletions(-) create mode 100644 packages/cli/src/utils/package.ts create mode 100644 packages/cli/src/utils/stdioHelpers.ts create mode 100644 packages/cli/src/utils/version.ts diff --git a/package-lock.json b/package-lock.json index 954b83b3..66cf2bce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -242,7 +242,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -384,7 +383,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1614,7 +1612,6 @@ "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { @@ -3732,6 +3729,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -4359,7 +4368,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -4630,7 +4638,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -5690,7 +5697,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", @@ -5708,7 +5714,6 @@ "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -5870,7 +5875,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -6130,6 +6134,101 @@ "node": ">=0.8" } }, + "node_modules/read-package-up": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-package-up/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/read-package-up/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/read-package-up/node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/read-package-up/node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-package-up/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/read-package-up/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/read-pkg": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", @@ -6584,7 +6683,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", @@ -6595,14 +6693,12 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, "license": "CC-BY-3.0" }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dev": true, "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", @@ -6613,7 +6709,6 @@ "version": "3.0.23", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "dev": true, "license": "CC0-1.0" }, "node_modules/sprintf-js": { @@ -7286,7 +7381,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", @@ -7621,6 +7715,7 @@ "ink": "^7.0.4", "ink-gradient": "^4.0.1", "react": "^19.2.5", + "read-package-up": "^12.0.0", "yargs": "^18.0.0" }, "bin": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 977bad59..1f657304 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,7 +25,7 @@ "scripts": { "typecheck": "tsc -p ./ --noEmit", "bundle": "node ../../scripts/esbuild.config.js", - "build": "npm run typecheck && npm run bundle && node ../../scripts/copy-bundle-assets.js && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", + "build": "npm run typecheck && npm run bundle && node ../../scripts/copy-bundle-assets.js && node -e \"import('node:fs').then(f => f.chmodSync('dist/cli.js', 0o755))\"", "prepublishOnly": "npm run build", "format": "prettier --write .", "test": "node src/tests/run-tests.mjs" @@ -38,6 +38,7 @@ "ink": "^7.0.4", "ink-gradient": "^4.0.1", "react": "^19.2.5", + "read-package-up": "^12.0.0", "yargs": "^18.0.0" }, "devDependencies": { diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts index 3851ac00..780869c0 100644 --- a/packages/cli/src/cli-args.ts +++ b/packages/cli/src/cli-args.ts @@ -3,7 +3,21 @@ * Uses yargs for robust argument parsing and validation. */ +import type { Argv } from "yargs"; import Yargs from "yargs"; +import { getCliVersion } from "./utils/version"; +import { writeStderrLine } from "./utils/stdioHelpers"; +import { hideBin } from "yargs/helpers"; + +// UUID v4 regex pattern for validation +const SESSION_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * Validates if a string is a valid session ID format. + */ +export function isValidSessionId(value: string): boolean { + return SESSION_ID_REGEX.test(value); +} export interface ParsedCliArgs { /** Prompt text from -p / --prompt */ @@ -21,68 +35,112 @@ export interface ParsedCliArgs { help: boolean; } -export interface CliParseError { - message: string; +const EPILOG = [ + "Configuration:", + " ~/.deepcode/settings.json User-level API key, model, base URL", + " ./.deepcode/settings.json Project-level settings", + " ./.deepcode/skills/*/SKILL.md Project-level native skills", + " ./.agents/skills/*/SKILL.md Project-level interoperable skills", + " ~/.deepcode/skills/*/SKILL.md User-level native skills", + " ~/.agents/skills/*/SKILL.md User-level interoperable skills", + "", + "Inside the TUI:", + " enter Send the prompt", + " shift+enter Insert a newline", + " home/end Move within the current line", + " alt+left/right Move by word", + " ctrl+w Delete the previous word", + " ctrl+v Paste an image from the clipboard", + " ctrl+x Clear pasted images", + " esc Interrupt the current model turn", + " / Open the skills/commands menu", + " /skills List available skills", + " /model Select model, thinking mode and effort control", + " /new Start a fresh conversation", + " /init Initialize an AGENTS.md file with instructions for LLM", + " /resume Pick a previous conversation to continue", + " /continue Continue the active conversation, or resume one if empty", + " /undo Restore code and/or conversation to a previous point", + " /mcp Show MCP server status and available tools", + " /raw Toggle display mode for viewing or collapsing reasoning content", + " /exit Quit", + " ctrl+d twice Quit", +].join("\n"); + +async function configureYargs(argv?: string[]) { + const rawArgv = argv ?? hideBin(process.argv); + const yargsInstance = Yargs(rawArgv) + .locale("en") + .scriptName("deepcode") + .usage( + "Usage: $0 [options] [command]\n\nDeep Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode" + ) + .command("$0 [query..]", "Launch Deep Code CLI", (yargsInstance: Argv) => + yargsInstance + .option("prompt", { + alias: "p", + type: "string", + describe: "Submit a prompt on launch", + }) + .option("resume", { + alias: "r", + type: "string", + describe: "Resume a specific session by its ID. Use without an ID to show session picker.", + }) + .check((argv: { [x: string]: unknown }) => { + const query = argv["query"] as string | string[] | undefined; + const hasPositionalQuery = Array.isArray(query) ? query.length > 0 : !!query; + + if (argv["prompt"] && hasPositionalQuery) { + return "Cannot use both a positional prompt and the --prompt (-p) flag together"; + } + // bare --resume conflicts with --prompt + if (argv["resume"] === "" && argv["prompt"]) { + return "Cannot use --resume without a session ID together with --prompt.\nUse --resume -p to resume a session and send a prompt."; + } + // validate --resume format if provided + if (argv["resume"] && argv["resume"] !== "" && !isValidSessionId(argv["resume"] as string)) { + return `Invalid session ID: "${argv["resume"]}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; + } + // empty prompt is meaningless + if (argv["prompt"] === "") { + return "--prompt / -p requires a non-empty value."; + } + return true; + }) + ) + .example("deepcode", "Launch the interactive TUI in the current directory") + .example("deepcode -p ", "Launch with a pre-filled prompt") + .example("deepcode -r, --resume [sessionId]", "Resume a session or show session picker") + .epilog(EPILOG) + .strict() + .demandCommand(0, 0) + .wrap(Math.min(process.stdout.columns || 80, 120)); + yargsInstance + .version(await getCliVersion()) + .alias("v", "version") + .help() + .alias("h", "help"); + yargsInstance.wrap(yargsInstance.terminalWidth()); + return yargsInstance; } /** * Parse CLI arguments with validation. - * Returns parsed args on success, or an error object if the arguments are invalid. + * + * On validation failure the `.fail()` handler prints the error, shows help, + * and calls `process.exit(1)`, so this function always either returns a + * valid `ParsedCliArgs` or terminates the process. */ -export function parseCliArgs(argv: string[]): ParsedCliArgs | CliParseError { - let validationError: string | null = null; - - const y = Yargs(argv) - .locale("en") - .scriptName("deepcode") - .version(false) - .help(false) - .option("version", { - alias: "v", - type: "boolean", - describe: "Print the version", - }) - .option("help", { - alias: "h", - type: "boolean", - describe: "Show this help", - }) - .option("resume", { - alias: "r", - type: "string", - describe: "Resume a specific session by its ID. Use without an ID to show session picker.", - }) - .option("prompt", { - alias: "p", - type: "string", - describe: "Submit a prompt on launch", - }) - .strict() - .exitProcess(false) - .fail((msg) => { - validationError = msg; - }) - .check((parsed) => { - // bare --resume conflicts with --prompt - if (parsed.resume === "" && parsed.prompt) { - throw new Error( - "Cannot use --resume without a session ID together with --prompt.\n" + - "Use --resume -p to resume a session and send a prompt." - ); - } - // empty prompt is meaningless - if (parsed.prompt === "") { - throw new Error("--prompt / -p requires a non-empty value."); - } - return true; - }); +export async function parseArguments(argv?: string[]): Promise { + const y = (await configureYargs(argv)).exitProcess(false).fail((msg, _err, yargs) => { + writeStderrLine(msg || _err?.message || "Unknown error"); + yargs.showHelp(); + process.exit(1); + }); const parsed = y.parseSync() as Record; - if (validationError) { - return { message: validationError }; - } - const resumeRaw = parsed.resume as string | undefined; let resume: ParsedCliArgs["resume"]; if (resumeRaw === undefined) { diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 1ea702bc..edf5fdbd 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -4,108 +4,53 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; -import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; +import { checkForNpmUpdate, promptForPendingUpdate } from "./common/update-check"; import { AppContainer } from "./ui"; -import { hideBin } from "yargs/helpers"; -import { parseCliArgs } from "./cli-args"; -import { CLI_VERSION, GIT_COMMIT_INFO } from "./generated/git-commit"; +import { parseArguments } from "./cli-args"; +import { writeStderrLine, writeStdoutLine } from "./utils/stdioHelpers"; +import { getPackageJson } from "./utils/package"; +import { CLI_VERSION } from "./generated/git-commit"; -const args = hideBin(process.argv); -const packageInfo = readPackageInfo(); - -const HELP_TEXT = - [ - "", - "Usage: deepcode [options] [command]\n\nDeep Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode", - "", - "Commands:", - " deepcode Launch the interactive TUI in the current directory", - "", - "Options:", - " -p, --prompt Launch with a pre-filled prompt", - " -r, --resume [sessionId] Resume a specific session by its ID. Use without an ID to show session picker", - " -v, --version Show version number", - " -h, --help Show help", - "", - "Configuration:", - " ~/.deepcode/settings.json User-level API key, model, base URL", - " ./.deepcode/settings.json Project-level settings", - " ./.deepcode/skills/*/SKILL.md Project-level native skills", - " ./.agents/skills/*/SKILL.md Project-level interoperable skills", - " ~/.deepcode/skills/*/SKILL.md User-level native skills", - " ~/.agents/skills/*/SKILL.md User-level interoperable skills", - "", - "Inside the TUI:", - " enter Send the prompt", - " shift+enter Insert a newline", - " home/end Move within the current line", - " alt+left/right Move by word", - " ctrl+w Delete the previous word", - " ctrl+v Paste an image from the clipboard", - " ctrl+x Clear pasted images", - " esc Interrupt the current model turn", - " / Open the skills/commands menu", - " /skills List available skills", - " /model Select model, thinking mode and effort control", - " /new Start a fresh conversation", - " /init Initialize an AGENTS.md file with instructions for LLM", - " /resume Pick a previous conversation to continue", - " /continue Continue the active conversation, or resume one if empty", - " /undo Restore code and/or conversation to a previous point", - " /mcp Show MCP server status and available tools", - " /raw Toggle display mode for viewing or collapsing reasoning content", - " /exit Quit", - " ctrl+d twice Quit", - ].join("\n") + "\n"; - -const parsed = parseCliArgs(args); - -if ("message" in parsed) { - process.stderr.write(parsed.message + "\n\n"); - process.stdout.write(HELP_TEXT); - process.exit(1); -} +configureWindowsShell(); +void main(); -if (parsed.version) { - process.stdout.write(`${packageInfo.version || "unknown"}\n`); - process.exit(0); -} +async function main(): Promise { + const packageInfo = await getPackageJson(); + const parsed = await parseArguments(); -if (parsed.help) { - process.stdout.write(HELP_TEXT); - process.exit(0); -} + // --version and --help are handled by yargs internally (prints output as side effect) + // but with .exitProcess(false) we need to exit manually. + if (parsed.version || parsed.help) { + process.exit(0); + } -let initialPrompt = parsed.prompt; -let resumeSessionId = parsed.resume; -const projectRoot = process.cwd(); -configureWindowsShell(); + let initialPrompt = parsed.prompt; + let resumeSessionId = parsed.resume; + const projectRoot = process.cwd(); -if (!process.stdin.isTTY) { - process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); - process.exit(1); -} + if (!process.stdin.isTTY) { + writeStderrLine("deepcode requires an interactive terminal (TTY). Re-run from a real terminal session.\n"); + process.exit(1); + } -// Validate --resume before entering TUI -if (typeof resumeSessionId === "string") { - const projectCode = getProjectCode(projectRoot); - const indexPath = join(homedir(), ".deepcode", "projects", projectCode, "sessions-index.json"); - try { - const index = JSON.parse(readFileSync(indexPath, "utf-8")); - const found = Array.isArray(index?.entries) && index.entries.some((e: { id: string }) => e.id === resumeSessionId); - if (!found) { - process.stderr.write(`No saved session found with ID "${resumeSessionId}".\n`); + // Validate --resume before entering TUI + if (typeof resumeSessionId === "string") { + const projectCode = getProjectCode(projectRoot); + const indexPath = join(homedir(), ".deepcode", "projects", projectCode, "sessions-index.json"); + try { + const index = JSON.parse(readFileSync(indexPath, "utf-8")); + const found = + Array.isArray(index?.entries) && index.entries.some((e: { id: string }) => e.id === resumeSessionId); + if (!found) { + writeStderrLine(`No saved session found with ID "${resumeSessionId}".\n`); + process.exit(1); + } + } catch { + writeStderrLine(`No saved session found with ID "${resumeSessionId}".\n`); process.exit(1); } - } catch { - process.stderr.write(`No saved session found with ID "${resumeSessionId}".\n`); - process.exit(1); } -} - -void main(); -async function main(): Promise { const updatePromptResult = await promptForPendingUpdate(packageInfo); if (updatePromptResult.installed) { process.exit(0); @@ -122,7 +67,7 @@ async function main(): Promise { const inkInstance = render( restartRef.current?.()} @@ -132,7 +77,7 @@ async function main(): Promise { restartRef.current = () => { restarting = true; - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); + writeStdoutLine("\u001B[2J\u001B[3J\u001B[H"); inkInstance.unmount(); startApp(); }; @@ -156,20 +101,7 @@ function configureWindowsShell(): void { setShellIfWindows(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`deepcode: ${message}\n`); + writeStderrLine(`deepcode: ${message}\n`); process.exit(1); } } - -function readPackageInfo(): PackageInfo { - try { - const pkg = require("../package.json") as { name?: unknown; version?: unknown }; - return { - name: typeof pkg.name === "string" ? pkg.name : "@vegamo/deepcode-cli", - version: typeof pkg.version === "string" ? pkg.version : (CLI_VERSION ?? ""), - gitCommit: GIT_COMMIT_INFO ?? "", - }; - } catch { - return { name: "@vegamo/deepcode-cli", version: CLI_VERSION ?? "", gitCommit: GIT_COMMIT_INFO ?? "" }; - } -} diff --git a/packages/cli/src/common/update-check.ts b/packages/cli/src/common/update-check.ts index 3b82e51a..fb387fe3 100644 --- a/packages/cli/src/common/update-check.ts +++ b/packages/cli/src/common/update-check.ts @@ -6,6 +6,7 @@ import * as path from "path"; import { render, type Instance } from "ink"; import { UpdatePrompt, type UpdatePromptChoice } from "../ui"; import { killProcessTree } from "@vegamo/deepcode-core"; +import type { PackageJson } from "../utils/package"; export type PackageInfo = { name: string; @@ -29,14 +30,14 @@ const MAX_NPM_VIEW_OUTPUT_CHARS = 64 * 1024; const TENCENT_MIRROR_REGISTRY = "https://mirrors.cloud.tencent.com/npm/"; export const UPDATE_SUCCESS_MESSAGE = "🎉 Update ran successfully! Please restart Deep Code."; -export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise<{ installed: boolean }> { +export async function promptForPendingUpdate(packageInfo: PackageJson): Promise<{ installed: boolean }> { const state = readUpdateState(); const pending = state.pending; if (!pending) { return { installed: false }; } - if (compareVersions(packageInfo.version, pending.latestVersion) >= 0) { + if (compareVersions(packageInfo.version!, pending.latestVersion) >= 0) { writeUpdateState({ ...state, pending: null }); return { installed: false }; } @@ -49,7 +50,7 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< const installSpec = `${pending.packageName}@${pending.latestVersion}`; const installCommand = `npm install -g ${installSpec}`; const choice = await promptUpdateChoice({ - currentVersion: packageInfo.version, + currentVersion: packageInfo.version!, latestVersion: pending.latestVersion, installCommand, }); @@ -73,7 +74,7 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< return { installed: false }; } -export async function checkForNpmUpdate(packageInfo: PackageInfo): Promise { +export async function checkForNpmUpdate(packageInfo: PackageJson): Promise { if (!packageInfo.name || !packageInfo.version) { return; } diff --git a/packages/cli/src/tests/cli-args.test.ts b/packages/cli/src/tests/cli-args.test.ts index fd97de76..fe90eeed 100644 --- a/packages/cli/src/tests/cli-args.test.ts +++ b/packages/cli/src/tests/cli-args.test.ts @@ -1,47 +1,59 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { parseCliArgs } from "../cli-args"; +import { parseArguments, isValidSessionId } from "../cli-args"; -// ── parseCliArgs: basic parsing ────────────────────────────────────────────── +// ── isValidSessionId ───────────────────────────────────────────────────────── -test("parseCliArgs returns prompt after -p", () => { - const r = parseCliArgs(["-p", "hello world"]); +test("isValidSessionId accepts valid UUID", () => { + assert.ok(isValidSessionId("0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6")); +}); + +test("isValidSessionId rejects invalid format", () => { + assert.ok(!isValidSessionId("not-a-uuid")); + assert.ok(!isValidSessionId("")); + assert.ok(!isValidSessionId("abc")); +}); + +// ── parseArguments: basic parsing ────────────────────────────────────────────── + +test("parseArguments returns prompt after -p", async () => { + const r = await parseArguments(["-p", "hello world"]); assert.ok(!("message" in r)); assert.equal(r.prompt, "hello world"); }); -test("parseCliArgs returns prompt after --prompt", () => { - const r = parseCliArgs(["--prompt", "hello world"]); +test("parseArguments returns prompt after --prompt", async () => { + const r = await parseArguments(["--prompt", "hello world"]); assert.ok(!("message" in r)); assert.equal(r.prompt, "hello world"); }); -test("parseCliArgs returns undefined prompt when -p is not present", () => { - const r = parseCliArgs(["--resume"]); +test("parseArguments returns undefined prompt when -p is not present", async () => { + const r = await parseArguments(["--resume"]); assert.ok(!("message" in r)); assert.equal(r.prompt, undefined); }); -test("parseCliArgs returns session ID after --resume", () => { - const r = parseCliArgs(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); +test("parseArguments returns session ID after --resume", async () => { + const r = await parseArguments(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); assert.ok(!("message" in r)); assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); }); -test("parseCliArgs returns true when --resume has no value", () => { - const r = parseCliArgs(["--resume"]); +test("parseArguments returns true when --resume has no value", async () => { + const r = await parseArguments(["--resume"]); assert.ok(!("message" in r)); assert.equal(r.resume, true); }); -test("parseCliArgs returns undefined resume when not present", () => { - const r = parseCliArgs(["-p", "test"]); +test("parseArguments returns undefined resume when not present", async () => { + const r = await parseArguments(["-p", "test"]); assert.ok(!("message" in r)); assert.equal(r.resume, undefined); }); -test("parseCliArgs returns defaults for empty args", () => { - const r = parseCliArgs([]); +test("parseArguments returns defaults for empty args", async () => { + const r = await parseArguments([]); assert.ok(!("message" in r)); assert.equal(r.prompt, undefined); assert.equal(r.resume, undefined); @@ -49,120 +61,151 @@ test("parseCliArgs returns defaults for empty args", () => { assert.equal(r.help, false); }); -// ── parseCliArgs: -r alias ─────────────────────────────────────────────────── +// ── parseArguments: -r alias ─────────────────────────────────────────────────── -test("parseCliArgs returns session ID after -r", () => { - const r = parseCliArgs(["-r", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); +test("parseArguments returns session ID after -r", async () => { + const r = await parseArguments(["-r", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); assert.ok(!("message" in r)); assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); }); -test("parseCliArgs returns true when -r has no value", () => { - const r = parseCliArgs(["-r"]); +test("parseArguments returns true when -r has no value", async () => { + const r = await parseArguments(["-r"]); assert.ok(!("message" in r)); assert.equal(r.resume, true); }); -test("parseCliArgs handles -r combined with -p", () => { - const r = parseCliArgs(["-r", "session-123", "-p", "hello"]); +test("parseArguments handles -r combined with -p", async () => { + const r = await parseArguments(["-r", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6", "-p", "hello"]); assert.ok(!("message" in r)); - assert.equal(r.resume, "session-123"); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); assert.equal(r.prompt, "hello"); }); -test("parseCliArgs rejects bare -r with -p", () => { - const r = parseCliArgs(["-r", "-p", "hello"]); - assert.ok("message" in r); - assert.match(r.message, /Cannot use --resume/); -}); - -// ── parseCliArgs: --version / --help ───────────────────────────────────────── +// ── parseArguments: --version / --help ───────────────────────────────────────── -test("parseCliArgs detects --version", () => { - const r = parseCliArgs(["--version"]); +test("parseArguments detects --version", async () => { + const r = await parseArguments(["--version"]); assert.ok(!("message" in r)); assert.equal(r.version, true); assert.equal(r.help, false); }); -test("parseCliArgs detects -v", () => { - const r = parseCliArgs(["-v"]); +test("parseArguments detects -v", async () => { + const r = await parseArguments(["-v"]); assert.ok(!("message" in r)); assert.equal(r.version, true); }); -test("parseCliArgs detects --help", () => { - const r = parseCliArgs(["--help"]); +test("parseArguments detects --help", async () => { + const r = await parseArguments(["--help"]); assert.ok(!("message" in r)); assert.equal(r.help, true); assert.equal(r.version, false); }); -test("parseCliArgs detects -h", () => { - const r = parseCliArgs(["-h"]); +test("parseArguments detects -h", async () => { + const r = await parseArguments(["-h"]); assert.ok(!("message" in r)); assert.equal(r.help, true); }); -test("parseCliArgs version and help are false when not passed", () => { - const r = parseCliArgs(["-p", "hello"]); +test("parseArguments version and help are false when not passed", async () => { + const r = await parseArguments(["-p", "hello"]); assert.ok(!("message" in r)); assert.equal(r.version, false); assert.equal(r.help, false); }); -test("parseCliArgs handles -v combined with -r (both flags set)", () => { - const r = parseCliArgs(["-v", "-r", "abc"]); +test("parseArguments handles -v combined with -r (both flags set)", async () => { + const r = await parseArguments(["-v", "-r", "abc"]); assert.ok(!("message" in r)); assert.equal(r.version, true); assert.equal(r.resume, "abc"); }); -// ── parseCliArgs: combined usage ───────────────────────────────────────────── +// ── parseArguments: combined usage ───────────────────────────────────────────── -test("parseCliArgs handles --resume combined with -p", () => { - const r = parseCliArgs(["--resume", "session-123", "-p", "hello"]); +test("parseArguments handles --resume combined with -p", async () => { + const r = await parseArguments(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6", "-p", "hello"]); assert.ok(!("message" in r)); - assert.equal(r.resume, "session-123"); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); assert.equal(r.prompt, "hello"); }); -test("parseCliArgs handles -p before --resume ", () => { - const r = parseCliArgs(["-p", "hello", "--resume", "session-123"]); +test("parseArguments handles -p before --resume ", async () => { + const r = await parseArguments(["-p", "hello", "--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); assert.ok(!("message" in r)); - assert.equal(r.resume, "session-123"); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); assert.equal(r.prompt, "hello"); }); -// ── parseCliArgs: validation ───────────────────────────────────────────────── - -test("parseCliArgs rejects bare --resume with -p", () => { - const r = parseCliArgs(["--resume", "-p", "hello"]); - assert.ok("message" in r); - assert.match(r.message, /Cannot use --resume/); -}); - -test("parseCliArgs rejects -p with bare --resume (reversed order)", () => { - const r = parseCliArgs(["-p", "hello", "--resume"]); - assert.ok("message" in r); - assert.match(r.message, /Cannot use --resume/); -}); - -test("parseCliArgs rejects unknown flags in strict mode", () => { - const r = parseCliArgs(["--unknown-flag"]); - assert.ok("message" in r); - assert.match(r.message, /Unknown argument/); -}); - -test("parseCliArgs rejects empty -p value", () => { - const r = parseCliArgs(["-p", ""]); - assert.ok("message" in r); - assert.match(r.message, /non-empty/); -}); - -test("parseCliArgs --version takes precedence over --help", () => { - const r = parseCliArgs(["--version", "--help"]); +test("parseArguments --version takes precedence over --help", async () => { + const r = await parseArguments(["--version", "--help"]); assert.ok(!("message" in r)); assert.equal(r.version, true); assert.equal(r.help, true); }); + +// ── parseArguments: error cases (mock process.exit) ──────────────────────────── +// Command-level and top-level errors both call process.exit(1) via yargs .fail(). + +function withMockedExit(fn: (exitSpy: { calls: number[] }) => Promise): Promise { + const original = process.exit; + const stderrWrite = process.stderr.write; + // Suppress yargs help/error output during tests + process.stderr.write = (() => true) as typeof process.stderr.write; + const exitSpy: { calls: number[] } = { calls: [] }; + process.exit = ((code?: number) => { + exitSpy.calls.push(code ?? 0); + throw new Error(`process.exit(${code})`); + }) as typeof process.exit; + return fn(exitSpy).finally(() => { + process.exit = original; + process.stderr.write = stderrWrite; + }); +} + +test("parseArguments exits on unknown flags", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["--unknown-flag"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); + +test("parseArguments exits on bare -r with -p", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["-r", "-p", "hello"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); + +test("parseArguments exits on empty -p value", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["-p", ""]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); + +test("parseArguments exits on invalid --resume session ID", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["--resume", "not-a-uuid"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts new file mode 100644 index 00000000..1f195294 --- /dev/null +++ b/packages/cli/src/utils/package.ts @@ -0,0 +1,29 @@ +import { readPackageUp, type PackageJson as BasePackageJson } from "read-package-up"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { CLI_VERSION } from "../generated/git-commit"; + +export type PackageJson = BasePackageJson & { + config?: { + sandboxImageUri?: string; + }; +}; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +let packageJson: PackageJson; + +export async function getPackageJson(): Promise { + if (packageJson) { + return packageJson; + } + + const result = await readPackageUp({ cwd: __dirname }); + if (!result) { + return { name: "@vegamo/deepcode-cli", version: CLI_VERSION ?? "" }; + } + + packageJson = result.packageJson; + return packageJson; +} diff --git a/packages/cli/src/utils/stdioHelpers.ts b/packages/cli/src/utils/stdioHelpers.ts new file mode 100644 index 00000000..f0202e99 --- /dev/null +++ b/packages/cli/src/utils/stdioHelpers.ts @@ -0,0 +1,25 @@ +/** + * Writes a message to stdout with a trailing newline. + * Use for normal command output that the user expects to see. + * Avoids double newlines if the message already ends with one. + */ +export const writeStdoutLine = (message: string): void => { + process.stdout.write(message.endsWith("\n") ? message : `${message}\n`); +}; + +/** + * Writes a message to stderr with a trailing newline. + * Use for error messages in CLI commands. + * Avoids double newlines if the message already ends with one. + */ +export const writeStderrLine = (message: string): void => { + process.stderr.write(message.endsWith("\n") ? message : `${message}\n`); +}; + +/** + * Clears the terminal screen. + * Use instead of console.clear() to satisfy no-console lint rules. + */ +export const clearScreen = (): void => { + console.clear(); +}; diff --git a/packages/cli/src/utils/version.ts b/packages/cli/src/utils/version.ts new file mode 100644 index 00000000..f41a5c1f --- /dev/null +++ b/packages/cli/src/utils/version.ts @@ -0,0 +1,6 @@ +import { getPackageJson } from "./package.js"; + +export async function getCliVersion(): Promise { + const pkgJson = await getPackageJson(); + return process.env["CLI_VERSION"] || pkgJson?.version || "unknown"; +} From 43d0c112f43090b24b099eaf608eabb8ba3e10e7 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Thu, 25 Jun 2026 14:49:14 +0800 Subject: [PATCH 27/43] fix: update code examples in statusline documentation and improve module provider abort handling --- docs/statusline.md | 4 +- docs/statusline_en.md | 2 +- packages/cli/src/tests/statusline.test.ts | 36 ++++++++++++++ .../cli/src/ui/statusline/module-provider.ts | 47 +++++++++++-------- 4 files changed, 66 insertions(+), 23 deletions(-) diff --git a/docs/statusline.md b/docs/statusline.md index 4ab8a11d..4c731276 100644 --- a/docs/statusline.md +++ b/docs/statusline.md @@ -6,7 +6,7 @@ Deep Code CLI 支持通过插件向终端底部状态栏注入自定义信息( 在 `~/.deepcode/settings.json`(或项目级 `.deepcode/settings.json`)中添加 `statusline` 字段: -```jsonc +```json { "statusline": { "enabled": true, @@ -100,7 +100,7 @@ export default function tokensProvider({ projectRoot, session }) { ## 安全限制 -- **module provider 路径必须位于项目根目录或用户家目录之下**,绝对路径在这两个范围之外会被拒绝加载(防止从任意位置执行代码)。 +- **module provider 路径必须位于项目根目录或用户home目录之下**,绝对路径在这两个范围之外会被拒绝加载(防止从任意位置执行代码)。 - 单个 segment 文本被自动: - 取第一个非空行 - 去除 ANSI 转义序列 diff --git a/docs/statusline_en.md b/docs/statusline_en.md index bd14d91a..340c32cc 100644 --- a/docs/statusline_en.md +++ b/docs/statusline_en.md @@ -6,7 +6,7 @@ Deep Code CLI lets you inject custom information into the status line at the bot Add a `statusline` field to `~/.deepcode/settings.json` (or the project-level `.deepcode/settings.json`): -```jsonc +```json { "statusline": { "enabled": true, diff --git a/packages/cli/src/tests/statusline.test.ts b/packages/cli/src/tests/statusline.test.ts index 4417cca1..0336b626 100644 --- a/packages/cli/src/tests/statusline.test.ts +++ b/packages/cli/src/tests/statusline.test.ts @@ -171,6 +171,42 @@ test("loadModuleProvider succeeds for a well-formed module", async () => { } }); +test("loadModuleProvider removes abort listener after successful fetch", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-statusline-")); + const modPath = path.join(dir, "cleanup.mjs"); + fs.writeFileSync(modPath, "export default () => 'ok';", "utf8"); + try { + const provider = await loadModuleProvider(modPath, undefined, "cleanup", 10_000); + assert.ok(provider); + + const ac = new AbortController(); + const signal = ac.signal; + const originalAdd = signal.addEventListener; + const originalRemove = signal.removeEventListener; + let abortListenerAdds = 0; + let abortListenerRemoves = 0; + signal.addEventListener = function (this: AbortSignal, ...args: Parameters) { + if (args[0] === "abort") { + abortListenerAdds += 1; + } + return originalAdd.apply(this, args); + } as AbortSignal["addEventListener"]; + signal.removeEventListener = function (this: AbortSignal, ...args: Parameters) { + if (args[0] === "abort") { + abortListenerRemoves += 1; + } + return originalRemove.apply(this, args); + } as AbortSignal["removeEventListener"]; + + const result = await provider!.fetch({ projectRoot: dir, signal }); + assert.equal(result, "ok"); + assert.equal(abortListenerAdds, 1); + assert.equal(abortListenerRemoves, 1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test("resolveSettingsSources lets project-level providers override user-level by id", () => { const resolved = resolveSettingsSources( { diff --git a/packages/cli/src/ui/statusline/module-provider.ts b/packages/cli/src/ui/statusline/module-provider.ts index 0222bb6c..f45d6ab2 100644 --- a/packages/cli/src/ui/statusline/module-provider.ts +++ b/packages/cli/src/ui/statusline/module-provider.ts @@ -63,26 +63,33 @@ export async function loadModuleProvider( if (ctx.signal.aborted) { return ""; } - const result = await Promise.race([ - Promise.resolve().then(() => - providerFn({ - projectRoot: ctx.projectRoot, - session: ctx.getSessionInfo ? ctx.getSessionInfo() : null, - }) - ), - new Promise((_, reject) => { - const timer = setTimeout(() => reject(new Error("timeout")), timeout); - ctx.signal.addEventListener( - "abort", - () => { - clearTimeout(timer); - reject(new Error("aborted")); - }, - { once: true } - ); - }), - ]); - return typeof result === "string" ? result : ""; + let timer: ReturnType | null = null; + let onAbort: (() => void) | null = null; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("timeout")), timeout); + onAbort = () => reject(new Error("aborted")); + ctx.signal.addEventListener("abort", onAbort, { once: true }); + }); + + try { + const result = await Promise.race([ + Promise.resolve().then(() => + providerFn({ + projectRoot: ctx.projectRoot, + session: ctx.getSessionInfo ? ctx.getSessionInfo() : null, + }) + ), + timeoutPromise, + ]); + return typeof result === "string" ? result : ""; + } finally { + if (timer) { + clearTimeout(timer); + } + if (onAbort) { + ctx.signal.removeEventListener("abort", onAbort); + } + } }, }; } catch { From 12b2c09f9652dd1b9f80c7bdf520037650873692 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 16:09:43 +0800 Subject: [PATCH 28/43] =?UTF-8?q?fix(cli):=20=E4=BF=AE=E5=A4=8D=20Windows?= =?UTF-8?q?=20Shell=20=E9=85=8D=E7=BD=AE=E6=97=B6=E7=9A=84=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=A1=8C=E5=8F=82=E6=95=B0=E5=A4=84=E7=90=86=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Windows Shell 配置逻辑调整到 --version 和 --help 参数处理之后 - 避免在 Windows 无 Git Bash 环境下配置 Shell 时提前退出进程 - 确保命令行参数如 --version 和 --help 正常工作 - 添加 configureWindowsShell 函数注释说明其调用时机和作用 - 清理 cli.tsx 中的导入和调用顺序,提高代码可读性 --- packages/cli/src/cli.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index edf5fdbd..af33bc42 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -11,7 +11,6 @@ import { writeStderrLine, writeStdoutLine } from "./utils/stdioHelpers"; import { getPackageJson } from "./utils/package"; import { CLI_VERSION } from "./generated/git-commit"; -configureWindowsShell(); void main(); async function main(): Promise { @@ -24,6 +23,11 @@ async function main(): Promise { process.exit(0); } + // Configure Windows shell AFTER --version/--help handling. + // On Windows without Git Bash, setShellIfWindows() throws and calls process.exit(1). + // If called before argument parsing, --help and --version would fail on those machines. + configureWindowsShell(); + let initialPrompt = parsed.prompt; let resumeSessionId = parsed.resume; const projectRoot = process.cwd(); @@ -95,6 +99,12 @@ async function main(): Promise { startApp(); } +/** + * Configure shell environment for Windows. + * Sets NoDefaultCurrentDirectoryInExePath and resolves Git Bash path. + * Must be called after --version/--help handling to avoid blocking those + * commands on Windows machines without Git Bash installed. + */ function configureWindowsShell(): void { process.env.NoDefaultCurrentDirectoryInExePath = "1"; try { From 7c8ece94105b964b4dcc92b73942462062cfbe90 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 16:22:29 +0800 Subject: [PATCH 29/43] =?UTF-8?q?refactor(cli):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20PackageInfo=20=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 packages/cli/src/common/update-check.ts 文件中未使用的 PackageInfo 类型 - 减少代码冗余,提升代码可维护性 - 保持类型定义的简洁性与准确性 --- packages/cli/src/common/update-check.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/cli/src/common/update-check.ts b/packages/cli/src/common/update-check.ts index fb387fe3..2dad85f8 100644 --- a/packages/cli/src/common/update-check.ts +++ b/packages/cli/src/common/update-check.ts @@ -8,12 +8,6 @@ import { UpdatePrompt, type UpdatePromptChoice } from "../ui"; import { killProcessTree } from "@vegamo/deepcode-core"; import type { PackageJson } from "../utils/package"; -export type PackageInfo = { - name: string; - version: string; - gitCommit?: string; -}; - type UpdateState = { pending?: { currentVersion: string; From 2fa60d54b78b9529f075c3ed8be2db0b6825ec9b Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 18:02:43 +0800 Subject: [PATCH 30/43] =?UTF-8?q?style(MessageView):=20=E5=9C=A8=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=A7=86=E5=9B=BE=E7=BB=84=E4=BB=B6=E4=B8=AD=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=B7=A6=E8=BE=B9=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为外层Box组件增加marginLeft样式属性 - 保持其他样式和布局不变 - 修正UI布局中提示符内容的左侧间距问题 --- packages/cli/src/ui/components/MessageView/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/MessageView/index.tsx b/packages/cli/src/ui/components/MessageView/index.tsx index 66df9625..a413f748 100644 --- a/packages/cli/src/ui/components/MessageView/index.tsx +++ b/packages/cli/src/ui/components/MessageView/index.tsx @@ -145,7 +145,7 @@ function PromptEchoLine({ }): React.ReactElement { const contentWidth = getPromptEchoContentWidth(width); return ( - + {"> "} From 545a4f54dd6bbb620c7f9d6cc907f17f659e5dc4 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 18:09:58 +0800 Subject: [PATCH 31/43] =?UTF-8?q?test(cli):=20=E8=B0=83=E6=95=B4=20Message?= =?UTF-8?q?View=20=E7=BB=84=E4=BB=B6=E4=B8=AD=E7=9A=84=E7=BC=A9=E8=BF=9B?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改了消息渲染输出的缩进,从无空格缩进改为增加空格 - 确保多行消息内容对齐显示更美观 - 更新相关测试断言以匹配新的缩进格式 --- packages/cli/src/tests/message-view.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/tests/message-view.test.ts b/packages/cli/src/tests/message-view.test.ts index fbd2b097..abe95ef3 100644 --- a/packages/cli/src/tests/message-view.test.ts +++ b/packages/cli/src/tests/message-view.test.ts @@ -132,7 +132,7 @@ test("MessageView echoes submitted user prompts with live prompt wrapping width" const msg = makeSessionMessage({ role: "user", content: "abcdefg" }); const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 }); - assert.equal(stripAnsi(output), "> abcdef\n g\n"); + assert.equal(stripAnsi(output), " > abcdef\n g\n"); }); test("MessageView echoes model changes with submitted prompt wrapping", () => { @@ -143,7 +143,7 @@ test("MessageView echoes model changes with submitted prompt wrapping", () => { }); const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 }); - assert.equal(stripAnsi(output), "> abcdef\n gh\n"); + assert.equal(stripAnsi(output), " > abcdef\n gh\n"); }); test("renderMessageToStdout renders assistant non-thinking messages with ✦", () => { From 7a447b80d86b7200bbf15003118e76363f2a1a47 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 19:22:18 +0800 Subject: [PATCH 32/43] =?UTF-8?q?refactor(cli):=20=E7=BB=9F=E4=B8=80stdio?= =?UTF-8?q?=E8=BE=85=E5=8A=A9=E5=87=BD=E6=95=B0=E5=AF=BC=E5=85=A5=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将cli.tsx和cli-args.ts中stdioHelpers的导入路径调整为统一的stdio-helpers格式 - 规范了模块文件命名,提高代码一致性 - 未改动核心功能逻辑,仅更改导入路径字符串 --- packages/cli/src/cli-args.ts | 2 +- packages/cli/src/cli.tsx | 2 +- packages/cli/src/utils/{stdioHelpers.ts => stdio-helpers.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/cli/src/utils/{stdioHelpers.ts => stdio-helpers.ts} (100%) diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts index 780869c0..b86eda41 100644 --- a/packages/cli/src/cli-args.ts +++ b/packages/cli/src/cli-args.ts @@ -6,7 +6,7 @@ import type { Argv } from "yargs"; import Yargs from "yargs"; import { getCliVersion } from "./utils/version"; -import { writeStderrLine } from "./utils/stdioHelpers"; +import { writeStderrLine } from "./utils/stdio-helpers"; import { hideBin } from "yargs/helpers"; // UUID v4 regex pattern for validation diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index af33bc42..80b11f08 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -7,7 +7,7 @@ import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; import { checkForNpmUpdate, promptForPendingUpdate } from "./common/update-check"; import { AppContainer } from "./ui"; import { parseArguments } from "./cli-args"; -import { writeStderrLine, writeStdoutLine } from "./utils/stdioHelpers"; +import { writeStderrLine, writeStdoutLine } from "./utils/stdio-helpers"; import { getPackageJson } from "./utils/package"; import { CLI_VERSION } from "./generated/git-commit"; diff --git a/packages/cli/src/utils/stdioHelpers.ts b/packages/cli/src/utils/stdio-helpers.ts similarity index 100% rename from packages/cli/src/utils/stdioHelpers.ts rename to packages/cli/src/utils/stdio-helpers.ts From 34ea71fd0d45064be8a0f82d1cec55db9455df68 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 25 Jun 2026 19:24:07 +0800 Subject: [PATCH 33/43] =?UTF-8?q?refactor(cli):=20=E7=BB=9F=E4=B8=80stdio?= =?UTF-8?q?=E8=BE=85=E5=8A=A9=E5=87=BD=E6=95=B0=E5=AF=BC=E5=85=A5=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将cli.tsx和cli-args.ts中stdioHelpers的导入路径调整为统一的stdio-helpers格式 - 规范了模块文件命名,提高代码一致性 - 未改动核心功能逻辑,仅更改导入路径字符串 --- packages/cli/src/utils/package.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts index 1f195294..3c1401af 100644 --- a/packages/cli/src/utils/package.ts +++ b/packages/cli/src/utils/package.ts @@ -3,11 +3,7 @@ import { fileURLToPath } from "node:url"; import path from "node:path"; import { CLI_VERSION } from "../generated/git-commit"; -export type PackageJson = BasePackageJson & { - config?: { - sandboxImageUri?: string; - }; -}; +export type PackageJson = BasePackageJson; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); From da9f09950854151e7c27185392cc5dced00fe850 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 26 Jun 2026 10:06:56 +0800 Subject: [PATCH 34/43] feat: move the resume hint out of the exit summary box --- packages/cli/src/tests/exit-summary.test.ts | 23 +++++++--- packages/cli/src/ui/exit-summary.ts | 16 +++---- packages/cli/src/ui/index.ts | 2 +- packages/cli/src/ui/views/App.tsx | 51 +++++++++++++++------ packages/cli/src/ui/views/PromptInput.tsx | 7 +-- 5 files changed, 67 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/tests/exit-summary.test.ts b/packages/cli/src/tests/exit-summary.test.ts index fd6b8ad0..45317b1e 100644 --- a/packages/cli/src/tests/exit-summary.test.ts +++ b/packages/cli/src/tests/exit-summary.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildExitSummaryText } from "../ui"; +import { buildExitSummaryText, buildResumeHintText } from "../ui"; import type { ModelUsage, SessionEntry } from "@vegamo/deepcode-core"; const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, ""); @@ -90,7 +90,7 @@ test("buildExitSummaryText does not derive usage rows from legacy aggregate usag assert.doesNotMatch(summary, /11,966/); }); -test("buildExitSummaryText shows resume hint when sessionId is provided", () => { +test("buildExitSummaryText does not show resume hint when sessionId is provided", () => { const sessionId = "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"; const summary = stripAnsi( buildExitSummaryText({ @@ -100,8 +100,8 @@ test("buildExitSummaryText shows resume hint when sessionId is provided", () => ); assert.match(summary, /Goodbye!/); - assert.match(summary, /deepcode --resume 0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6/); - assert.match(summary, /To continue this session/); + assert.doesNotMatch(summary, /deepcode --resume 0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6/); + assert.doesNotMatch(summary, /To continue this session/); }); test("buildExitSummaryText does not show resume hint when sessionId is omitted", () => { @@ -116,7 +116,7 @@ test("buildExitSummaryText does not show resume hint when sessionId is omitted", assert.doesNotMatch(summary, /To continue this session/); }); -test("buildExitSummaryText shows resume hint with null session", () => { +test("buildExitSummaryText does not show resume hint with null session", () => { const summary = stripAnsi( buildExitSummaryText({ session: null, @@ -125,7 +125,18 @@ test("buildExitSummaryText shows resume hint with null session", () => { ); assert.match(summary, /Goodbye!/); - assert.match(summary, /deepcode --resume test-session-id/); + assert.doesNotMatch(summary, /deepcode --resume test-session-id/); + assert.doesNotMatch(summary, /To continue this session/); +}); + +test("buildResumeHintText shows resume command when sessionId is provided", () => { + const hint = stripAnsi(buildResumeHintText("0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6") ?? ""); + + assert.equal(hint, "To continue this session, run deepcode --resume 0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); +}); + +test("buildResumeHintText returns null when sessionId is omitted", () => { + assert.equal(buildResumeHintText(), null); }); function buildSession(usage: ModelUsage | null, usagePerModel: Record | null = null): SessionEntry { diff --git a/packages/cli/src/ui/exit-summary.ts b/packages/cli/src/ui/exit-summary.ts index 1a28ab8f..67db1280 100644 --- a/packages/cli/src/ui/exit-summary.ts +++ b/packages/cli/src/ui/exit-summary.ts @@ -68,7 +68,7 @@ function extractUsageFields(usage: ModelUsage | null): UsageFields { } export function buildExitSummaryText(input: ExitSummaryInput): string { - const { session, sessionId } = input; + const { session } = input; const innerWidth = 98; const contentWidth = innerWidth - 4; // "│ " prefix + " │" suffix → 4 chars padding @@ -135,13 +135,6 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { rows.push(""); - if (sessionId) { - const resumeHint = - chalk.dim(`To continue this session, run `) + chalk.hex("#229ac3")(`deepcode --resume ${sessionId}`); - rows.push(resumeHint); - rows.push(""); - } - const border = borderColor("─".repeat(innerWidth)); const top = `${borderColor("╭")}${border}${borderColor("╮")}`; const bottom = `${borderColor("╰")}${border}${borderColor("╯")}`; @@ -150,3 +143,10 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { return [top, body, bottom].join("\n"); } + +export function buildResumeHintText(sessionId?: string): string | null { + if (!sessionId) { + return null; + } + return chalk.dim(`To continue this session, run `) + chalk.hex("#229ac3")(`deepcode --resume ${sessionId}`); +} diff --git a/packages/cli/src/ui/index.ts b/packages/cli/src/ui/index.ts index 8f155360..65415464 100644 --- a/packages/cli/src/ui/index.ts +++ b/packages/cli/src/ui/index.ts @@ -90,4 +90,4 @@ export { type FileMentionToken, } from "./core/file-mentions"; export { findExpandedThinkingId, isCollapsedThinking } from "./core/thinking-state"; -export { buildExitSummaryText } from "./exit-summary"; +export { buildExitSummaryText, buildResumeHintText } from "./exit-summary"; diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 337832d5..456030c3 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -20,7 +20,7 @@ import { formatAskUserQuestionAnswers, } from "../core/ask-user-question"; import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt"; -import { buildExitSummaryText } from "../exit-summary"; +import { buildExitSummaryText, buildResumeHintText } from "../exit-summary"; import { RawMode, useRawModeContext } from "../contexts"; import { renderMessageToStdout } from "../components/MessageView/utils"; import { @@ -290,22 +290,40 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp }, [sessionManager]); writeRef.current = write; - const handlePrompt = useCallback( - async (submission: PromptSubmission) => { - if (submission.command === "exit") { - setIsExiting(true); - setTimeout(() => { - const activeSessionId = sessionManager.getActiveSessionId(); - const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; - const summary = buildExitSummaryText({ session, sessionId: activeSessionId ?? undefined }); - process.stdout.write("\n"); + const handleExit = useCallback( + ({ showCommand, showSummary }: { showCommand: boolean; showSummary: boolean }) => { + setIsExiting(true); + setTimeout(() => { + const activeSessionId = sessionManager.getActiveSessionId(); + const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; + const resumeHint = buildResumeHintText(activeSessionId ?? undefined); + + process.stdout.write("\n"); + if (showCommand) { process.stdout.write(chalk.rgb(34, 154, 195)("> /exit ")); process.stdout.write("\n\n"); + } + if (showSummary) { + const summary = buildExitSummaryText({ session, sessionId: activeSessionId ?? undefined }); process.stdout.write(summary); process.stdout.write("\n\n"); - sessionManager.dispose(); - exit(); - }, 0); + } + if (resumeHint) { + process.stdout.write(resumeHint); + process.stdout.write("\n"); + } + + sessionManager.dispose(); + exit(); + }, 0); + }, + [exit, sessionManager] + ); + + const handlePrompt = useCallback( + async (submission: PromptSubmission) => { + if (submission.command === "exit") { + handleExit({ showCommand: true, showSummary: true }); return; } if (submission.command === "new") { @@ -400,7 +418,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp [ sessionManager, pendingPermissionReply, - exit, + handleExit, onRestart, refreshSkills, refreshSessionsList, @@ -477,6 +495,10 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp [handlePrompt] ); + const handleExitShortcut = useCallback(() => { + handleExit({ showCommand: false, showSummary: false }); + }, [handleExit]); + const reloadActiveSessionView = useCallback( (sessionId: string): void => { resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); @@ -959,6 +981,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp onRawModeChange={handleRawModeChange} onInterrupt={handleInterrupt} onToggleProcessStdout={handleToggleProcessStdout} + onExitShortcut={handleExitShortcut} placeholder="Type your message..." statusLineSegments={statusLineSegments} statusLineSeparator={resolvedSettings.statusline.separator} diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 2bf720b1..3f548def 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useApp, useStdout } from "ink"; +import { Box, Text, useStdout } from "ink"; import type { DOMElement } from "ink"; import chalk from "chalk"; import { ARGS_SEPARATOR } from "../constants"; @@ -101,6 +101,7 @@ type Props = { onRawModeChange?: (mode: string) => void; onInterrupt: () => void; onToggleProcessStdout?: () => void; + onExitShortcut?: () => void; }; const PROMPT_PREFIX_WIDTH = 2; @@ -132,9 +133,9 @@ export const PromptInput = React.memo(function PromptInput({ onModelConfigChange, onInterrupt, onToggleProcessStdout, + onExitShortcut, onRawModeChange, }: Props): React.ReactElement { - const { exit } = useApp(); const { stdout } = useStdout(); const inputTextRef = useRef(null); const [buffer, setBuffer] = useState(EMPTY_BUFFER); @@ -357,7 +358,7 @@ export const PromptInput = React.memo(function PromptInput({ } const now = Date.now(); if (pendingExit && now - lastCtrlDAt.current < 2000) { - exit(); + onExitShortcut?.(); return; } lastCtrlDAt.current = now; From 377e04161e925de4c3e7413946020e4dc48c4dcb Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 26 Jun 2026 10:36:58 +0800 Subject: [PATCH 35/43] fix: the new left margin need to be included in width calculations --- packages/cli/src/tests/message-view.test.ts | 20 ++++++++++++++++--- .../src/ui/components/MessageView/index.tsx | 6 ++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/tests/message-view.test.ts b/packages/cli/src/tests/message-view.test.ts index abe95ef3..c1c2d69d 100644 --- a/packages/cli/src/tests/message-view.test.ts +++ b/packages/cli/src/tests/message-view.test.ts @@ -127,12 +127,19 @@ test("renderMessageToStdout shows (no content) for empty user messages", () => { }); test("MessageView echoes submitted user prompts with live prompt wrapping width", () => { - assert.equal(getPromptEchoContentWidth(8), 6); + assert.equal(getPromptEchoContentWidth(8), 5); const msg = makeSessionMessage({ role: "user", content: "abcdefg" }); const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 }); - assert.equal(stripAnsi(output), " > abcdef\n g\n"); + const text = stripAnsi(output); + assert.equal(text, " > abcde\n fg\n"); + assert.ok( + text + .trimEnd() + .split("\n") + .every((line) => line.length <= 8) + ); }); test("MessageView echoes model changes with submitted prompt wrapping", () => { @@ -143,7 +150,14 @@ test("MessageView echoes model changes with submitted prompt wrapping", () => { }); const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 }); - assert.equal(stripAnsi(output), " > abcdef\n gh\n"); + const text = stripAnsi(output); + assert.equal(text, " > abcde\n fgh\n"); + assert.ok( + text + .trimEnd() + .split("\n") + .every((line) => line.length <= 8) + ); }); test("renderMessageToStdout renders assistant non-thinking messages with ✦", () => { diff --git a/packages/cli/src/ui/components/MessageView/index.tsx b/packages/cli/src/ui/components/MessageView/index.tsx index a413f748..12b8229d 100644 --- a/packages/cli/src/ui/components/MessageView/index.tsx +++ b/packages/cli/src/ui/components/MessageView/index.tsx @@ -13,6 +13,7 @@ import type { DiffPreviewLine, MessageViewProps } from "./types"; import { RawMode, useRawModeContext } from "../../contexts"; const PROMPT_ECHO_PREFIX_WIDTH = 2; +const PROMPT_ECHO_MARGIN_LEFT = 1; export function MessageView({ message, collapsed, width = 80 }: MessageViewProps): React.ReactElement | null { const { mode } = useRawModeContext(); @@ -131,7 +132,7 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps } export function getPromptEchoContentWidth(width: number): number { - return Math.max(1, width - PROMPT_ECHO_PREFIX_WIDTH); + return Math.max(1, width - PROMPT_ECHO_MARGIN_LEFT - PROMPT_ECHO_PREFIX_WIDTH); } function PromptEchoLine({ @@ -144,8 +145,9 @@ function PromptEchoLine({ attachmentCount?: number; }): React.ReactElement { const contentWidth = getPromptEchoContentWidth(width); + const containerWidth = Math.max(1, width - PROMPT_ECHO_MARGIN_LEFT); return ( - + {"> "} From ada6ca74f293425621216986482b0b155ccaed01 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 26 Jun 2026 13:28:26 +0800 Subject: [PATCH 36/43] feat: update AGENTS.md --- .deepcode/AGENTS.md | 16 +++++++++++----- README.md | 12 ++++++++---- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md index a1611307..7471cc15 100644 --- a/.deepcode/AGENTS.md +++ b/.deepcode/AGENTS.md @@ -14,12 +14,17 @@ packages/ │ ├── prompt.ts # System prompt builder & tool definitions │ └── settings.ts # Settings resolution from ~/.deepcode/settings.json ├── cli/src/ # Terminal UI (Ink/React) -│ ├── cli.tsx # Entry point — parses args (-p, -v), renders AppContainer -│ ├── ui/views/ # Top-level screens (App, PromptInput, SessionList, PermissionPrompt, etc.) +│ ├── cli.tsx # Entry point — renders AppContainer +│ ├── cli-args.ts # CLI argument parsing (yargs: -p, -r, -v, -h) +│ ├── common/ # Update checker +│ ├── utils/ # stdio helpers, version, package info +│ ├── generated/ # Build-time git commit info +│ ├── ui/views/ # Top-level screens (App, PromptInput, SessionList, PermissionPrompt, WelcomeScreen, UpdatePrompt, McpStatusList, etc.) │ ├── ui/components/ # Reusable Ink components (MessageView, DropdownMenu, ModelsDropdown, etc.) │ ├── ui/core/ # Prompt buffer, slash commands, file mentions, clipboard, undo/redo -│ ├── ui/hooks/ # Custom hooks (cursor, history navigation, paste handling, terminal input) +│ ├── ui/hooks/ # Custom hooks (cursor, history navigation, paste handling, terminal input, statusline) │ ├── ui/contexts/ # React contexts (AppContext, RawModeContext) +│ ├── ui/statusline/ # Pluggable statusline providers (command, module) │ └── tests/ # UI-focused tests with run-tests.mjs runner ├── vscode-ide-companion/ # VSCode extension companion │ └── src/ # extension.ts, provider.ts, utils.ts @@ -45,6 +50,7 @@ All commands run from the repo root. | `npm run check` | Runs typecheck + lint + format:check together | | `npm run build` | Orchestrates full build (scripts/build.js) — compiles core + bundles CLI + copies assets | | `npm run bundle` | Generates git commit info + esbuild bundle + copies bundled assets | +| `npm run build:vscode` | Builds the VSCode extension companion | | `npm test` | Runs all workspace tests (`npm run test --workspaces --if-present`) | | `npm run start` | Runs the locally built CLI (`scripts/start.js`) | | `npm run build-and-start` | Builds then starts the CLI | @@ -115,9 +121,9 @@ A **file history system** (`packages/core/src/common/file-history.ts`) provides **Slash commands**: `/skills`, `/model`, `/new`, `/init`, `/resume`, `/continue`, `/undo`, `/mcp`, `/raw`, `/exit`, plus dynamic `/skill-name` for each loaded skill. -**Key UI features**: `@` file mentions in the prompt input, `Ctrl+O` to view live process stdout, `Ctrl+V` to paste images, Shift+Enter for newlines, MCP server status display, undo selector, and permission prompts. +**Key UI features**: `@` file mentions in the prompt input, `Ctrl+O` to view live process stdout, `Ctrl+V` to paste images, `Ctrl+X` to clear images, Shift+Enter for newlines, pluggable statusline, MCP server status display, undo selector, and permission prompts. -**CLI flags**: `-p ` / `--prompt` to auto-submit a prompt on launch, `-v` / `--version`, `-h` / `--help`. +**CLI flags**: `-p ` / `--prompt` to auto-submit a prompt on launch, `-r [sessionId]` / `--resume [sessionId]` to resume a session or show the session picker, `-v` / `--version`, `-h` / `--help`. ## Agent-Specific Instructions diff --git a/README.md b/README.md index cde02314..39cd12bd 100644 --- a/README.md +++ b/README.md @@ -159,14 +159,18 @@ cd deepcode-cli # 安装依赖 npm install -# 本地开发(类型检查 + lint + 格式检查 + 构建) -npm run build - # 运行测试 npm test -# 链接到全局(即本地全局安装) +# CLI本地开发(类型检查 + lint + 格式检查 + 构建) +npm run build + +# CLI链接到全局(即本地全局安装) npm link + +# VSCode插件本地开发 +npm run build:vscode + ``` - 提交 PR 前请确保 `npm run check` 通过(类型检查 + lint + 格式检查) From 83c139efc8514578e089041b5319ff82afbaeb6a Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 26 Jun 2026 14:15:42 +0800 Subject: [PATCH 37/43] fix: initial-session Markdown rendering for VSCode extension --- packages/vscode-ide-companion/src/provider.ts | 10 +++++++--- .../vscode-ide-companion/src/tests/extension.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/vscode-ide-companion/src/provider.ts b/packages/vscode-ide-companion/src/provider.ts index 91aee0e4..2e3b4ac8 100644 --- a/packages/vscode-ide-companion/src/provider.ts +++ b/packages/vscode-ide-companion/src/provider.ts @@ -62,7 +62,7 @@ export async function handleWebviewMessage(message: unknown, deps: ProviderDeps) const msg = message as Record; if (msg.type === "ready") { - loadInitialSession(sessionManager, postMessage); + loadInitialSession(sessionManager, postMessage, renderMarkdown); await sendSkillsList(sessionManager, postMessage); return true; } @@ -138,7 +138,11 @@ export async function handleWebviewMessage(message: unknown, deps: ProviderDeps) return false; } -function loadInitialSession(sessionManager: ProviderDeps["sessionManager"], postMessage: PostMessageFn): void { +function loadInitialSession( + sessionManager: ProviderDeps["sessionManager"], + postMessage: PostMessageFn, + renderMarkdown: (text: string) => string +): void { const sessions = sessionManager.listSessions(); const sessionsList = toSessionList(sessions); @@ -152,7 +156,7 @@ function loadInitialSession(sessionManager: ProviderDeps["sessionManager"], post } const latestSession = sessions[0]; - loadSession(latestSession.id, sessionManager, postMessage, (t) => t); + loadSession(latestSession.id, sessionManager, postMessage, renderMarkdown); } export function loadSession( diff --git a/packages/vscode-ide-companion/src/tests/extension.test.ts b/packages/vscode-ide-companion/src/tests/extension.test.ts index 4f8d6e03..03f8e4e7 100644 --- a/packages/vscode-ide-companion/src/tests/extension.test.ts +++ b/packages/vscode-ide-companion/src/tests/extension.test.ts @@ -82,6 +82,18 @@ test("ready message triggers loadInitialSession and sendSkillsList", async () => assert.ok(types.includes("skillsList"), `Expected skillsList, got: ${types.join(", ")}`); }); +test("ready message renders markdown for initial session messages", async () => { + const deps = createDeps({ + messages: [{ role: "assistant", content: "**bold**", visible: true }], + }); + + await handleWebviewMessage({ type: "ready" }, deps); + + const loadMsg = deps.messages.find((m: any) => m.type === "loadSession") as any; + assert.ok(loadMsg, "Should send loadSession"); + assert.equal(loadMsg.messages[0].html, "

**bold**

"); +}); + test("ready with no sessions sends initializeEmpty", async () => { const deps = createDeps({ sessions: [] }); await handleWebviewMessage({ type: "ready" }, deps); From f2972d4547ab18788022ea5b8133878ba859b7c5 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 26 Jun 2026 14:27:20 +0800 Subject: [PATCH 38/43] chore: update package versions to 0.1.32 for deepcode-cli and deepcode-core, and 0.1.23 for deepcode-vscode --- package-lock.json | 6 +++--- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 66cf2bce..00eda577 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7705,7 +7705,7 @@ }, "packages/cli": { "name": "@vegamo/deepcode-cli", - "version": "0.1.31", + "version": "0.1.32", "license": "MIT", "dependencies": { "@vegamo/deepcode-core": "file:../core", @@ -7751,7 +7751,7 @@ }, "packages/core": { "name": "@vegamo/deepcode-core", - "version": "0.1.31", + "version": "0.1.32", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -7786,7 +7786,7 @@ }, "packages/vscode-ide-companion": { "name": "deepcode-vscode", - "version": "0.1.22", + "version": "0.1.23", "license": "MIT", "dependencies": { "@vegamo/deepcode-core": "file:../core", diff --git a/packages/cli/package.json b/packages/cli/package.json index 1f657304..e3959fc4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.31", + "version": "0.1.32", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index 1f924389..41c9da31 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-core", - "version": "0.1.31", + "version": "0.1.32", "description": "Deep Code core library — LLM session management, tool execution, and shared utilities", "license": "MIT", "type": "module", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 6369f37b..fc7f8118 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -1,6 +1,6 @@ { "name": "deepcode-vscode", - "version": "0.1.22", + "version": "0.1.23", "publisher": "vegamo", "displayName": "Deep Code", "description": "Deep Code VSCode companion — AI-assisted development in your editor", From e47ef58cc94a155144fd1bd53f9eeddde9443df0 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 26 Jun 2026 14:53:49 +0800 Subject: [PATCH 39/43] chore: update package versions to 0.1.33 for deepcode-cli and deepcode-core, and enhance package validation in prepare-package script --- package-lock.json | 4 +- packages/cli/package.json | 4 +- packages/core/package.json | 2 +- scripts/prepare-package.js | 167 +++++++++++++++++++++++++++++++------ 4 files changed, 148 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index 00eda577..5c894763 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7705,7 +7705,7 @@ }, "packages/cli": { "name": "@vegamo/deepcode-cli", - "version": "0.1.32", + "version": "0.1.33", "license": "MIT", "dependencies": { "@vegamo/deepcode-core": "file:../core", @@ -7751,7 +7751,7 @@ }, "packages/core": { "name": "@vegamo/deepcode-core", - "version": "0.1.32", + "version": "0.1.33", "license": "MIT", "dependencies": { "chalk": "^5.6.2", diff --git a/packages/cli/package.json b/packages/cli/package.json index e3959fc4..c860e690 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.32", + "version": "0.1.33", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", @@ -15,6 +15,8 @@ "main": "./dist/cli.js", "files": [ "dist/cli.js", + "dist/chunks/**", + "dist/templates/**", "dist/bundled/**", "README.md", "LICENSE" diff --git a/packages/core/package.json b/packages/core/package.json index 41c9da31..bac0f126 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-core", - "version": "0.1.32", + "version": "0.1.33", "description": "Deep Code core library — LLM session management, tool execution, and shared utilities", "license": "MIT", "type": "module", diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index 02481c50..eb12dfea 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -27,18 +27,20 @@ function ok(msg) { function run(cmd, args, opts = {}) { const label = opts.label ?? `${cmd} ${args.join(" ")}`; - if (opts.dryRun) { + if (opts.dryRun && !opts.runInDryRun) { log(` (dry-run) ${label}`); return { status: 0, stdout: "" }; } const result = spawnSync(cmd, args, { stdio: opts.stdio ?? "inherit", cwd: opts.cwd ?? root, - shell: true, + shell: false, + encoding: opts.encoding, env: { ...process.env, ...opts.env }, }); if (result.status !== 0) { - fail(`Command failed: ${label}`); + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + fail(`Command failed: ${label}${output ? `\n${output}` : ""}`); } return result; } @@ -55,6 +57,70 @@ function isValidSemver(v) { return /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/.test(v); } +function isValidNpmTag(v) { + return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v); +} + +function hasPackFile(files, expectedPath) { + return files.includes(expectedPath); +} + +function hasPackPrefix(files, expectedPrefix) { + return files.some((file) => file.startsWith(expectedPrefix)); +} + +function validatePacklist(cwd, checks, opts = {}) { + const label = opts.label ?? `npm pack --dry-run --json --ignore-scripts`; + const result = run("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd, + label, + stdio: "pipe", + encoding: "utf-8", + runInDryRun: true, + }); + const output = result.stdout.trim(); + const packs = JSON.parse(output); + const pack = Array.isArray(packs) ? packs[0] : packs; + const files = (pack?.files ?? []).map((file) => file.path); + const missing = []; + + for (const check of checks) { + const found = check.type === "prefix" ? hasPackPrefix(files, check.value) : hasPackFile(files, check.value); + if (!found) { + missing.push(check.label ?? check.value); + } + } + + if (missing.length > 0) { + fail(`Package tarball is missing required files:\n - ${missing.join("\n - ")}`); + } + + ok(`Validated package tarball (${files.length} files)`); +} + +function hasGitChanges(paths) { + const result = spawnSync("git", ["diff", "--quiet", "--", ...paths], { + cwd: root, + shell: false, + }); + if (result.status === 0) { + return false; + } + if (result.status === 1) { + return true; + } + fail("Unable to check release file changes."); +} + +function gitTagExists(tagName) { + const result = spawnSync("git", ["rev-parse", "-q", "--verify", `refs/tags/${tagName}`], { + cwd: root, + shell: false, + stdio: "ignore", + }); + return result.status === 0; +} + // ── Parse args ─────────────────────────────────────────────────────────────── const args = process.argv.slice(2); @@ -103,6 +169,10 @@ if (!isValidSemver(version)) { fail(`Invalid semver version: ${version}`); } +if (!isValidNpmTag(tag)) { + fail(`Invalid npm dist-tag: ${tag}`); +} + const TOTAL_STEPS = 8; // ── Banner ─────────────────────────────────────────────────────────────────── @@ -119,7 +189,7 @@ step(1, TOTAL_STEPS, "Checking git state..."); const gitStatus = spawnSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf-8", - shell: true, + shell: false, }); if (gitStatus.stdout.trim()) { fail("Working tree is not clean. Commit or stash changes first."); @@ -130,7 +200,7 @@ if (!force) { const gitBranch = spawnSync("git", ["branch", "--show-current"], { cwd: root, encoding: "utf-8", - shell: true, + shell: false, }); const branch = gitBranch.stdout.trim(); if (branch !== "main") { @@ -147,7 +217,7 @@ if (!dryRun) { const whoami = spawnSync("npm", ["whoami"], { cwd: root, encoding: "utf-8", - shell: true, + shell: false, }); if (whoami.status !== 0) { fail("Not logged in to npm. Run `npm login` first."); @@ -182,6 +252,12 @@ if (!dryRun) { log(` (dry-run) packages/cli: ${oldVersion} → ${version}`); } +run("npm", ["install", "--package-lock-only", "--ignore-scripts"], { + dryRun, + label: "npm install --package-lock-only --ignore-scripts", +}); +ok("package-lock.json is up to date"); + // ── 4. Quality checks ──────────────────────────────────────────────────────── step(4, TOTAL_STEPS, "Running quality checks (typecheck + lint + format)..."); @@ -209,6 +285,7 @@ const cliRoot = join(root, "packages", "cli"); const distDir = join(cliRoot, "dist"); const distCliJs = join(distDir, "cli.js"); const distChunks = join(distDir, "chunks"); +const distTemplates = join(distDir, "templates"); const distBundled = join(distDir, "bundled"); if (!existsSync(distCliJs)) { @@ -217,10 +294,24 @@ if (!existsSync(distCliJs)) { if (!existsSync(distChunks)) { fail(`Chunks directory not found: ${distChunks}. Run "npm run build" first.`); } +if (!existsSync(distTemplates)) { + fail(`Templates directory not found: ${distTemplates}. Run "npm run build" first.`); +} if (!existsSync(distBundled)) { fail(`Bundled assets not found: ${distBundled}. Run "npm run build" first.`); } +validatePacklist( + cliRoot, + [ + { type: "file", value: "dist/cli.js" }, + { type: "prefix", value: "dist/chunks/", label: "dist/chunks/*.js" }, + { type: "prefix", value: "dist/templates/", label: "dist/templates/**" }, + { type: "prefix", value: "dist/bundled/", label: "dist/bundled/**" }, + ], + { label: "cd packages/cli && npm pack --dry-run --json --ignore-scripts" } +); + // Copy README.md and LICENSE into dist/ for (const file of ["README.md", "LICENSE"]) { const src = join(root, file); @@ -258,9 +349,53 @@ if (!dryRun) { } log(" Written dist/package.json with dependencies: {}"); +if (!dryRun) { + validatePacklist( + distDir, + [ + { type: "file", value: "cli.js" }, + { type: "prefix", value: "chunks/", label: "chunks/*.js" }, + { type: "prefix", value: "templates/", label: "templates/**" }, + { type: "prefix", value: "bundled/", label: "bundled/**" }, + ], + { label: "cd dist && npm pack --dry-run --json --ignore-scripts" } + ); +} else { + log(" (dry-run) skipped dist/package.json tarball validation because dist/package.json was not written"); +} + ok("dist/ prepared for publishing"); -// ── 7. Publish from dist/ ──────────────────────────────────────────────────── +// ── Git commit + tag ───────────────────────────────────────────────────────── + +const releaseFiles = ["packages/core/package.json", "packages/cli/package.json", "package-lock.json"]; +const tagName = `v${version}`; + +if (!dryRun) { + log("\nCreating git commit and tag..."); + if (hasGitChanges(releaseFiles)) { + run("git", ["add", ...releaseFiles], { + label: "git add packages/*/package.json package-lock.json", + }); + run("git", ["commit", "-m", `chore(release): v${version}`], { + label: `git commit -m "chore(release): v${version}"`, + }); + } else { + log(" No release file changes to commit; tagging current HEAD"); + } + + if (gitTagExists(tagName)) { + fail(`Git tag already exists: ${tagName}`); + } + run("git", ["tag", tagName], { + label: `git tag ${tagName}`, + }); + ok(`Created tag ${tagName}`); +} else { + log("\n (dry-run) git add + commit + tag"); +} + +// ── 8. Publish from dist/ ──────────────────────────────────────────────────── step(8, TOTAL_STEPS, "Publishing @vegamo/deepcode-cli from dist/..."); @@ -274,24 +409,6 @@ run("npm", publishArgs, { }); ok(`Published @vegamo/deepcode-cli@${version}`); -// ── Git commit + tag ───────────────────────────────────────────────────────── - -if (!dryRun) { - log("\nCreating git commit and tag..."); - run("git", ["add", "packages/core/package.json", "packages/cli/package.json"], { - label: "git add packages/*/package.json", - }); - run("git", ["commit", "-m", `chore(release): v${version}`], { - label: `git commit -m "chore(release): v${version}"`, - }); - run("git", ["tag", `v${version}`], { - label: `git tag v${version}`, - }); - ok(`Created commit and tag v${version}`); -} else { - log("\n (dry-run) git add + commit + tag"); -} - // ── Done ───────────────────────────────────────────────────────────────────── console.log("\n========================================="); From c18d0d81cadda282677190ef31c80fa473522a8d Mon Sep 17 00:00:00 2001 From: hcyang Date: Fri, 26 Jun 2026 15:24:19 +0800 Subject: [PATCH 40/43] =?UTF-8?q?refactor(ui):=20=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E6=89=80=E6=9C=89=20process.stdout.write=20=E4=B8=BA=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E7=9A=84=E5=86=99=E5=85=A5=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 writeStdoutLine 函数替代重复的 process.stdout.write 调用 - 优化多处命令行输出,提升代码一致性和可维护性 - 修改退出提示、会话渲染及屏幕清理相关逻辑使用新函数 - 防止状态行在退出时显示,提升界面表现稳定性 --- packages/cli/src/ui/views/App.tsx | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 456030c3..24788502 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -48,6 +48,7 @@ import type { } from "@vegamo/deepcode-core"; import { SessionManager } from "@vegamo/deepcode-core"; import { getCompactPromptTokenThreshold } from "@vegamo/deepcode-core"; +import { writeStdoutLine } from "../../utils/stdio-helpers"; type View = "chat" | "session-list" | "undo" | "mcp-status"; @@ -145,8 +146,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp onAssistantMessage: (message: SessionMessage) => { setMessages((prev) => [...prev, message]); if (rawModeRef.current === RawMode.Raw) { - process.stdout.write("\n"); - process.stdout.write(renderMessageToStdout(message, rawModeRef.current) + "\n\n"); + writeStdoutLine("\n"); + writeStdoutLine(renderMessageToStdout(message, rawModeRef.current) + "\n\n"); } }, onSessionEntryUpdated: (entry) => { @@ -196,7 +197,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const resetStaticView = useCallback( (loadedMessages: SessionMessage[], options?: { clearScreen?: boolean }): Promise => { if (options?.clearScreen) { - process.stdout.write(ANSI_CLEAR_SCREEN); + writeStdoutLine(ANSI_CLEAR_SCREEN); } setMessages([]); setWelcomeNonce((n) => n + 1); @@ -298,19 +299,19 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; const resumeHint = buildResumeHintText(activeSessionId ?? undefined); - process.stdout.write("\n"); + writeStdoutLine("\n"); if (showCommand) { - process.stdout.write(chalk.rgb(34, 154, 195)("> /exit ")); - process.stdout.write("\n\n"); + writeStdoutLine(chalk.rgb(34, 154, 195)(" > /exit ")); + writeStdoutLine("\n"); } if (showSummary) { const summary = buildExitSummaryText({ session, sessionId: activeSessionId ?? undefined }); - process.stdout.write(summary); - process.stdout.write("\n\n"); + writeStdoutLine(summary); + writeStdoutLine("\n"); } if (resumeHint) { - process.stdout.write(resumeHint); - process.stdout.write("\n"); + writeStdoutLine(resumeHint); + writeStdoutLine("\n"); } sessionManager.dispose(); @@ -628,7 +629,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setShowWelcome(false); setMessages([]); // Clear screen to remove stale formatted text. - process.stdout.write(ANSI_CLEAR_SCREEN); + writeStdoutLine(ANSI_CLEAR_SCREEN); setTimeout(() => { if (nextMode === RawMode.Raw) { @@ -667,7 +668,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp if (mode === RawMode.Raw) { // In raw mode, re-render all messages directly to stdout at the new width. // Use process.stdout.write instead of writeRef to avoid Ink interference. - process.stdout.write(ANSI_CLEAR_SCREEN); + writeStdoutLine(ANSI_CLEAR_SCREEN); const activeSessionId = sessionManager.getActiveSessionId(); const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; renderRawModeMessages(allMessages, mode); @@ -898,7 +899,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp ); }}
- {busy || statusLine ? : null} + {(busy || statusLine) && !isExiting ? : null} {errorLine ? ( Error: {errorLine} From 47d1f03fafce9901142c27a0e7da699dea0e55ef Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Fri, 26 Jun 2026 17:22:42 +0800 Subject: [PATCH 41/43] feat: enhance prompt attachment manager to support multiple image attachments and improve preview handling --- .../resources/prompt-attachments.js | 72 ++++-- .../resources/webview.css | 1 + .../src/tests/extension.test.ts | 20 ++ .../src/tests/prompt-attachments.test.ts | 240 ++++++++++++++++++ 4 files changed, 307 insertions(+), 26 deletions(-) create mode 100644 packages/vscode-ide-companion/src/tests/prompt-attachments.test.ts diff --git a/packages/vscode-ide-companion/resources/prompt-attachments.js b/packages/vscode-ide-companion/resources/prompt-attachments.js index 81bfc174..e6c628ac 100644 --- a/packages/vscode-ide-companion/resources/prompt-attachments.js +++ b/packages/vscode-ide-companion/resources/prompt-attachments.js @@ -48,9 +48,11 @@ throw new Error("Prompt attachment manager requires promptInput, inputWrap, and toolsLine."); } - let attachment = null; + let attachments = []; + let nextAttachmentId = 0; let previewPopup = null; let previewImage = null; + let previewAnchor = null; function ensurePreviewPopup() { if (previewPopup) { @@ -68,6 +70,7 @@ if (!previewPopup) { return; } + previewAnchor = null; previewPopup.classList.remove("show"); } @@ -101,12 +104,13 @@ previewPopup.style.top = top + "px"; } - function showPreview(anchor) { + function showPreview(anchor, attachment) { if (!attachment) { return; } ensurePreviewPopup(); + previewAnchor = anchor; previewImage.src = attachment.dataUrl; previewPopup.classList.add("show"); updatePreviewPosition(anchor); @@ -114,24 +118,35 @@ function emitChange() { onAttachmentChange({ - hasAttachments: Boolean(attachment), - attachments: attachment ? [attachment] : [], + hasAttachments: attachments.length > 0, + attachments: attachments.slice(), }); } function clear() { - attachment = null; + attachments = []; toolsLine.innerHTML = ""; toolsLine.classList.remove("has-attachment"); hidePreview(); emitChange(); } - function createAttachmentNode() { + function removeAttachment(id) { + const nextAttachments = attachments.filter((attachment) => attachment.id !== id); + if (nextAttachments.length === attachments.length) { + return; + } + attachments = nextAttachments; + render(); + emitChange(); + } + + function createAttachmentNode(attachment) { const wrapper = createElement("div", "chat-attached-context-attachment show-file-icons"); wrapper.tabIndex = 0; wrapper.setAttribute("role", "button"); wrapper.setAttribute("aria-label", ATTACHMENT_LABEL + " (删除)"); + wrapper.dataset.attachmentId = String(attachment.id); wrapper.draggable = true; const removeButton = createElement("a", "monaco-button codicon codicon-close"); @@ -143,7 +158,7 @@ removeButton.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); - clear(); + removeAttachment(attachment.id); }); const iconLabel = createElement("div", "monaco-icon-label"); @@ -166,7 +181,7 @@ wrapper.appendChild(pill); wrapper.appendChild(text); - const show = () => showPreview(wrapper); + const show = () => showPreview(wrapper, attachment); wrapper.addEventListener("mouseenter", show); wrapper.addEventListener("focus", show); wrapper.addEventListener("mouseleave", hidePreview); @@ -177,7 +192,7 @@ wrapper.addEventListener("keydown", (event) => { if (event.key === "Delete" || event.key === "Backspace") { event.preventDefault(); - clear(); + removeAttachment(attachment.id); } }); @@ -186,37 +201,44 @@ function render() { toolsLine.innerHTML = ""; - toolsLine.classList.toggle("has-attachment", Boolean(attachment)); - if (!attachment) { + toolsLine.classList.toggle("has-attachment", attachments.length > 0); + if (attachments.length === 0) { hidePreview(); return; } - toolsLine.appendChild(createAttachmentNode()); + for (const attachment of attachments) { + toolsLine.appendChild(createAttachmentNode(attachment)); + } + if (previewAnchor && !toolsLine.contains(previewAnchor)) { + hidePreview(); + } } - function setAttachmentData(data) { + function addAttachmentData(data) { if (!data?.dataUrl) { return false; } - attachment = { + nextAttachmentId += 1; + attachments.push({ + id: nextAttachmentId, name: data.name || ATTACHMENT_LABEL, mimeType: data.mimeType || "image/png", dataUrl: data.dataUrl, label: ATTACHMENT_LABEL, - }; + }); render(); emitChange(); return true; } - async function setAttachmentFromFile(file) { + async function addAttachmentFromFile(file) { if (!isImageFile(file)) { return false; } const dataUrl = await readFileAsDataUrl(file); - return setAttachmentData({ + return addAttachmentData({ name: file.name || ATTACHMENT_LABEL, mimeType: file.type || "image/png", dataUrl, @@ -232,7 +254,7 @@ event.preventDefault(); try { - await setAttachmentFromFile(file); + await addAttachmentFromFile(file); } catch (error) { console.error("Failed to attach pasted image.", error); } @@ -241,18 +263,16 @@ promptInput.addEventListener("paste", handlePaste); window.addEventListener("resize", () => { - const attachmentNode = toolsLine.querySelector(".chat-attached-context-attachment"); - if (previewPopup?.classList.contains("show") && attachmentNode) { - updatePreviewPosition(attachmentNode); + if (previewPopup?.classList.contains("show") && previewAnchor) { + updatePreviewPosition(previewAnchor); } }); window.addEventListener( "scroll", () => { - const attachmentNode = toolsLine.querySelector(".chat-attached-context-attachment"); - if (previewPopup?.classList.contains("show") && attachmentNode) { - updatePreviewPosition(attachmentNode); + if (previewPopup?.classList.contains("show") && previewAnchor) { + updatePreviewPosition(previewAnchor); } }, true @@ -261,10 +281,10 @@ return { clear, hasAttachments() { - return Boolean(attachment); + return attachments.length > 0; }, getImageUrls() { - return attachment ? [attachment.dataUrl] : []; + return attachments.map((attachment) => attachment.dataUrl); }, }; } diff --git a/packages/vscode-ide-companion/resources/webview.css b/packages/vscode-ide-companion/resources/webview.css index ea1d71a2..98ffa62e 100644 --- a/packages/vscode-ide-companion/resources/webview.css +++ b/packages/vscode-ide-companion/resources/webview.css @@ -1121,6 +1121,7 @@ body { .tools-line { display: none; align-items: center; + flex-wrap: wrap; gap: 8px; min-height: 0; padding: 0 12px; diff --git a/packages/vscode-ide-companion/src/tests/extension.test.ts b/packages/vscode-ide-companion/src/tests/extension.test.ts index 03f8e4e7..9ea39656 100644 --- a/packages/vscode-ide-companion/src/tests/extension.test.ts +++ b/packages/vscode-ide-companion/src/tests/extension.test.ts @@ -267,6 +267,26 @@ test("userPrompt with images sends userMessage with image placeholder", async () assert.equal((userMsg as any).content, "粘贴的图像"); }); +test("userPrompt passes multiple image urls to the session manager", async () => { + const deps = createDeps(); + let submittedPrompt: any = null; + (deps.sessionManager as any).handleUserPrompt = (prompt: any) => { + submittedPrompt = prompt; + return Promise.resolve(); + }; + + await handleWebviewMessage( + { + type: "userPrompt", + prompt: "", + images: ["data:image/png;base64,abc", "data:image/jpeg;base64,def"], + }, + deps + ); + + assert.deepEqual(submittedPrompt?.imageUrls, ["data:image/png;base64,abc", "data:image/jpeg;base64,def"]); +}); + test("userPrompt with permissions (continue) does not send userMessage", async () => { const deps = createDeps(); await handleWebviewMessage( diff --git a/packages/vscode-ide-companion/src/tests/prompt-attachments.test.ts b/packages/vscode-ide-companion/src/tests/prompt-attachments.test.ts new file mode 100644 index 00000000..314826e3 --- /dev/null +++ b/packages/vscode-ide-companion/src/tests/prompt-attachments.test.ts @@ -0,0 +1,240 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +type EventHandler = (event: any) => unknown; + +class FakeClassList { + private readonly classes = new Set(); + + constructor(className = "") { + for (const classPart of className.split(/\s+/)) { + if (classPart) { + this.classes.add(classPart); + } + } + } + + add(className: string): void { + this.classes.add(className); + } + + remove(className: string): void { + this.classes.delete(className); + } + + contains(className: string): boolean { + return this.classes.has(className); + } + + toggle(className: string, force?: boolean): boolean { + const shouldAdd = force ?? !this.classes.has(className); + if (shouldAdd) { + this.classes.add(className); + } else { + this.classes.delete(className); + } + return shouldAdd; + } +} + +class FakeElement { + readonly tagName: string; + className = ""; + classList = new FakeClassList(); + children: FakeElement[] = []; + parent: FakeElement | null = null; + dataset: Record = {}; + style: Record = {}; + textContent = ""; + tabIndex = 0; + draggable = false; + href = ""; + src = ""; + alt = ""; + private readonly attributes = new Map(); + private readonly listeners = new Map(); + + constructor(tagName: string) { + this.tagName = tagName; + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + appendChild(child: FakeElement): FakeElement { + child.parent = this; + this.children.push(child); + return child; + } + + set innerHTML(_value: string) { + for (const child of this.children) { + child.parent = null; + } + this.children = []; + } + + get innerHTML(): string { + return ""; + } + + addEventListener(type: string, handler: EventHandler): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(handler); + this.listeners.set(type, listeners); + } + + async dispatchEvent(event: any): Promise { + event.type ??= ""; + for (const handler of this.listeners.get(event.type) ?? []) { + await handler(event); + } + } + + contains(candidate: FakeElement | null): boolean { + if (!candidate) { + return false; + } + if (candidate === this) { + return true; + } + return this.children.some((child) => child.contains(candidate)); + } + + querySelector(selector: string): FakeElement | null { + if (!selector.startsWith(".")) { + return null; + } + const className = selector.slice(1); + for (const child of this.children) { + if (child.className.split(/\s+/).includes(className)) { + return child; + } + const match = child.querySelector(selector); + if (match) { + return match; + } + } + return null; + } + + getBoundingClientRect(): { left: number; top: number; bottom: number; width: number; height: number } { + return { left: 20, top: 80, bottom: 100, width: 160, height: 40 }; + } +} + +class FakeDocument { + readonly body = new FakeElement("body"); + + createElement(tagName: string): FakeElement { + return new FakeElement(tagName); + } +} + +class FakeFileReader { + result: string | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + error: Error | null = null; + + readAsDataURL(file: { dataUrl?: string }): void { + this.result = file.dataUrl ?? ""; + this.onload?.(); + } +} + +function loadAttachmentManager(): { + manager: { clear: () => void; hasAttachments: () => boolean; getImageUrls: () => string[] }; + promptInput: FakeElement; + toolsLine: FakeElement; +} { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const scriptPath = path.resolve(__dirname, "../../resources/prompt-attachments.js"); + const script = fs.readFileSync(scriptPath, "utf8"); + + const document = new FakeDocument(); + const window = { + innerWidth: 1024, + innerHeight: 768, + addEventListener: () => {}, + createPromptAttachmentManager: undefined as + | undefined + | ((options: Record) => { + clear: () => void; + hasAttachments: () => boolean; + getImageUrls: () => string[]; + }), + }; + + vm.runInNewContext(script, { console, document, window, FileReader: FakeFileReader }); + + const createPromptAttachmentManager = window.createPromptAttachmentManager; + if (typeof createPromptAttachmentManager !== "function") { + throw new Error("Prompt attachment manager was not registered."); + } + const promptInput = new FakeElement("textarea"); + const inputWrap = new FakeElement("div"); + const toolsLine = new FakeElement("div"); + const manager = createPromptAttachmentManager({ promptInput, inputWrap, toolsLine }); + + return { manager, promptInput, toolsLine }; +} + +async function pasteImage(promptInput: FakeElement, dataUrl: string): Promise { + let defaultPrevented = false; + await promptInput.dispatchEvent({ + type: "paste", + clipboardData: { + items: [ + { + kind: "file", + getAsFile: () => ({ type: "image/png", name: "image.png", dataUrl }), + }, + ], + }, + preventDefault: () => { + defaultPrevented = true; + }, + }); + assert.equal(defaultPrevented, true); +} + +test("prompt attachment manager appends pasted images instead of replacing the previous image", async () => { + const { manager, promptInput, toolsLine } = loadAttachmentManager(); + + await pasteImage(promptInput, "data:image/png;base64,first"); + await pasteImage(promptInput, "data:image/png;base64,second"); + + assert.equal(manager.hasAttachments(), true); + assert.deepEqual(Array.from(manager.getImageUrls()), ["data:image/png;base64,first", "data:image/png;base64,second"]); + assert.equal(toolsLine.children.length, 2); + assert.equal(toolsLine.classList.contains("has-attachment"), true); +}); + +test("prompt attachment manager removes one pasted image without clearing the rest", async () => { + const { manager, promptInput, toolsLine } = loadAttachmentManager(); + + await pasteImage(promptInput, "data:image/png;base64,first"); + await pasteImage(promptInput, "data:image/png;base64,second"); + + const firstAttachment = toolsLine.children[0]; + const removeButton = firstAttachment.children[0]; + await removeButton.dispatchEvent({ + type: "click", + preventDefault: () => {}, + stopPropagation: () => {}, + }); + + assert.deepEqual(Array.from(manager.getImageUrls()), ["data:image/png;base64,second"]); + assert.equal(toolsLine.children.length, 1); + + manager.clear(); + assert.equal(manager.hasAttachments(), false); + assert.deepEqual(Array.from(manager.getImageUrls()), []); + assert.equal(toolsLine.children.length, 0); +}); From 888a20ac01116ccf32f76bee4782bac916cc0dd3 Mon Sep 17 00:00:00 2001 From: Ji Zhang Date: Mon, 29 Jun 2026 11:48:22 +0800 Subject: [PATCH 42/43] feat: added writeStdout() for exact stdout writes with no trailing newline. --- packages/cli/src/ui/views/App.tsx | 10 +++++----- packages/cli/src/utils/stdio-helpers.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 24788502..3b2886cd 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -48,7 +48,7 @@ import type { } from "@vegamo/deepcode-core"; import { SessionManager } from "@vegamo/deepcode-core"; import { getCompactPromptTokenThreshold } from "@vegamo/deepcode-core"; -import { writeStdoutLine } from "../../utils/stdio-helpers"; +import { writeStdout, writeStdoutLine } from "../../utils/stdio-helpers"; type View = "chat" | "session-list" | "undo" | "mcp-status"; @@ -197,7 +197,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const resetStaticView = useCallback( (loadedMessages: SessionMessage[], options?: { clearScreen?: boolean }): Promise => { if (options?.clearScreen) { - writeStdoutLine(ANSI_CLEAR_SCREEN); + writeStdout(ANSI_CLEAR_SCREEN); } setMessages([]); setWelcomeNonce((n) => n + 1); @@ -629,7 +629,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setShowWelcome(false); setMessages([]); // Clear screen to remove stale formatted text. - writeStdoutLine(ANSI_CLEAR_SCREEN); + writeStdout(ANSI_CLEAR_SCREEN); setTimeout(() => { if (nextMode === RawMode.Raw) { @@ -667,8 +667,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp if (mode === RawMode.Raw) { // In raw mode, re-render all messages directly to stdout at the new width. - // Use process.stdout.write instead of writeRef to avoid Ink interference. - writeStdoutLine(ANSI_CLEAR_SCREEN); + // Use direct stdout instead of writeRef to avoid Ink interference. + writeStdout(ANSI_CLEAR_SCREEN); const activeSessionId = sessionManager.getActiveSessionId(); const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; renderRawModeMessages(allMessages, mode); diff --git a/packages/cli/src/utils/stdio-helpers.ts b/packages/cli/src/utils/stdio-helpers.ts index f0202e99..3f117267 100644 --- a/packages/cli/src/utils/stdio-helpers.ts +++ b/packages/cli/src/utils/stdio-helpers.ts @@ -1,3 +1,11 @@ +/** + * Writes a message to stdout exactly as provided. + * Use for terminal control sequences or output that manages its own spacing. + */ +export const writeStdout = (message: string): void => { + process.stdout.write(message); +}; + /** * Writes a message to stdout with a trailing newline. * Use for normal command output that the user expects to see. From 8625cb3a055129f338d32f740c1ab1afc7ebeeea Mon Sep 17 00:00:00 2001 From: hcyang Date: Mon, 29 Jun 2026 14:03:15 +0800 Subject: [PATCH 43/43] =?UTF-8?q?style(cli):=20=E4=BC=98=E5=8C=96=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E6=8F=8F=E8=BF=B0=E7=9A=84=E5=B8=83=E5=B1=80=E9=97=B4?= =?UTF-8?q?=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在选项描述外围添加了左侧外边距的容器Box - 改进了文本显示的缩进和视觉层次感 - 保持了原有的文本颜色和显示逻辑不变 - 提升了用户界面的一致性和可读性 --- packages/cli/src/ui/views/AskUserQuestionPrompt.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/views/AskUserQuestionPrompt.tsx b/packages/cli/src/ui/views/AskUserQuestionPrompt.tsx index a2f91adb..ccce5f7e 100644 --- a/packages/cli/src/ui/views/AskUserQuestionPrompt.tsx +++ b/packages/cli/src/ui/views/AskUserQuestionPrompt.tsx @@ -206,7 +206,11 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): )} ) : null} - {option.description ? {option.description} : null} + {option.description ? ( + + {option.description} + + ) : null} ); })}