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/.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/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 + 格式检查) 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/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/docs/statusline.md b/docs/statusline.md new file mode 100644 index 00000000..4c731276 --- /dev/null +++ b/docs/statusline.md @@ -0,0 +1,149 @@ +# 状态栏插件 + +Deep Code CLI 支持通过插件向终端底部状态栏注入自定义信息(Git 分支、当前时间、token 用量等),无需修改 CLI 源码。状态栏行展示在输入框下方的快捷键提示行下方,所有 provider 的输出会用分隔符拼接后渲染。 + +## 配置 + +在 `~/.deepcode/settings.json`(或项目级 `.deepcode/settings.json`)中添加 `statusline` 字段: + +```json +{ + "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 路径必须位于项目根目录或用户home目录之下**,绝对路径在这两个范围之外会被拒绝加载(防止从任意位置执行代码)。 +- 单个 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..340c32cc --- /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`): + +```json +{ + "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/package-lock.json b/package-lock.json index 0e4b47f0..00bcd28f 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": { @@ -1647,6 +1644,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", @@ -1965,6 +1979,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", @@ -2724,6 +2742,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 +3187,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 +3343,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" @@ -3657,6 +3733,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", @@ -3753,6 +3841,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", @@ -4275,7 +4372,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" @@ -4546,7 +4642,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": { @@ -5606,7 +5701,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", @@ -5624,7 +5718,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" @@ -5786,7 +5879,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": { @@ -6046,6 +6138,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", @@ -6500,7 +6687,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", @@ -6511,14 +6697,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", @@ -6529,7 +6713,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": { @@ -7202,7 +7385,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", @@ -7385,6 +7567,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 +7600,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", @@ -7475,7 +7709,7 @@ }, "packages/cli": { "name": "@vegamo/deepcode-cli", - "version": "0.1.31", + "version": "0.1.33", "license": "MIT", "dependencies": { "@vegamo/deepcode-core": "file:../core", @@ -7484,11 +7718,16 @@ "ignore": "^7.0.5", "ink": "^7.0.4", "ink-gradient": "^4.0.1", - "react": "^19.2.5" + "react": "^19.2.5", + "read-package-up": "^12.0.0", + "yargs": "^18.0.0" }, "bin": { "deepcode": "dist/cli.js" }, + "devDependencies": { + "@types/yargs": "^17.0.35" + }, "engines": { "node": ">=22" } @@ -7516,7 +7755,7 @@ }, "packages/core": { "name": "@vegamo/deepcode-core", - "version": "0.1.31", + "version": "0.1.33", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -7549,9 +7788,24 @@ "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", + "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 654038ee..c860e690 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.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" @@ -25,7 +27,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" @@ -37,6 +39,11 @@ "ignore": "^7.0.5", "ink": "^7.0.4", "ink-gradient": "^4.0.1", - "react": "^19.2.5" + "react": "^19.2.5", + "read-package-up": "^12.0.0", + "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 new file mode 100644 index 00000000..b86eda41 --- /dev/null +++ b/packages/cli/src/cli-args.ts @@ -0,0 +1,160 @@ +/** + * CLI argument parsing helpers. + * 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/stdio-helpers"; +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 */ + 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; +} + +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. + * + * 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 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; + + 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 { + 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 c595916b..80b11f08 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,83 +1,60 @@ import React from "react"; import { render } from "ink"; -import { setShellIfWindows } from "@vegamo/deepcode-core"; -import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; +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 } from "./common/update-check"; import { AppContainer } from "./ui"; +import { parseArguments } from "./cli-args"; +import { writeStderrLine, writeStdoutLine } from "./utils/stdio-helpers"; +import { getPackageJson } from "./utils/package"; +import { CLI_VERSION } from "./generated/git-commit"; -const args = process.argv.slice(2); -const packageInfo = readPackageInfo(); - -if (args.includes("--version") || args.includes("-v")) { - process.stdout.write(`${packageInfo.version || "unknown"}\n`); - process.exit(0); -} +void main(); -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 --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" - ); - process.exit(0); -} +async function main(): Promise { + const packageInfo = await getPackageJson(); + const parsed = await parseArguments(); -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]; + // --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); } - return undefined; -} -let initialPrompt = extractInitialPrompt(args); -const projectRoot = process.cwd(); -configureWindowsShell(); + // 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(); -if (!process.stdin.isTTY) { - process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); - process.exit(1); -} + let initialPrompt = parsed.prompt; + let resumeSessionId = parsed.resume; + const projectRoot = process.cwd(); -void main(); + 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) { + 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); + } + } -async function main(): Promise { const updatePromptResult = await promptForPendingUpdate(packageInfo); if (updatePromptResult.installed) { process.exit(0); @@ -89,11 +66,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 } @@ -101,7 +81,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(); }; @@ -119,25 +99,19 @@ 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 { 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 : "", - }; - } catch { - return { name: "@vegamo/deepcode-cli", version: "" }; - } -} diff --git a/packages/cli/src/common/update-check.ts b/packages/cli/src/common/update-check.ts index 7a4710be..2dad85f8 100644 --- a/packages/cli/src/common/update-check.ts +++ b/packages/cli/src/common/update-check.ts @@ -6,11 +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"; - -export type PackageInfo = { - name: string; - version: string; -}; +import type { PackageJson } from "../utils/package"; type UpdateState = { pending?: { @@ -28,14 +24,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 }; } @@ -48,7 +44,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, }); @@ -72,7 +68,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 new file mode 100644 index 00000000..fe90eeed --- /dev/null +++ b/packages/cli/src/tests/cli-args.test.ts @@ -0,0 +1,211 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseArguments, isValidSessionId } from "../cli-args"; + +// ── isValidSessionId ───────────────────────────────────────────────────────── + +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("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("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("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("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("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("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); + assert.equal(r.version, false); + assert.equal(r.help, false); +}); + +// ── parseArguments: -r alias ─────────────────────────────────────────────────── + +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("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("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, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); + assert.equal(r.prompt, "hello"); +}); + +// ── parseArguments: --version / --help ───────────────────────────────────────── + +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("parseArguments detects -v", async () => { + const r = await parseArguments(["-v"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); +}); + +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("parseArguments detects -h", async () => { + const r = await parseArguments(["-h"]); + assert.ok(!("message" in r)); + assert.equal(r.help, true); +}); + +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("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"); +}); + +// ── parseArguments: combined usage ───────────────────────────────────────────── + +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, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); + assert.equal(r.prompt, "hello"); +}); + +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, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); + assert.equal(r.prompt, "hello"); +}); + +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/tests/exit-summary.test.ts b/packages/cli/src/tests/exit-summary.test.ts index e0d481db..45317b1e 100644 --- a/packages/cli/src/tests/exit-summary.test.ts +++ b/packages/cli/src/tests/exit-summary.test.ts @@ -1,9 +1,9 @@ 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;]*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( @@ -90,6 +90,55 @@ test("buildExitSummaryText does not derive usage rows from legacy aggregate usag assert.doesNotMatch(summary, /11,966/); }); +test("buildExitSummaryText does not show 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.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", () => { + 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 does not show resume hint with null session", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: null, + sessionId: "test-session-id", + }) + ); + + assert.match(summary, /Goodbye!/); + 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 { return { id: "session-1", diff --git a/packages/cli/src/tests/message-view.test.ts b/packages/cli/src/tests/message-view.test.ts index fbd2b097..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/tests/statusline.test.ts b/packages/cli/src/tests/statusline.test.ts new file mode 100644 index 00000000..0336b626 --- /dev/null +++ b/packages/cli/src/tests/statusline.test.ts @@ -0,0 +1,312 @@ +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, resolveSettingsSources } 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("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( + { + 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, + 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/components/MessageView/index.tsx b/packages/cli/src/ui/components/MessageView/index.tsx index 66df9625..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 ( - + {"> "} diff --git a/packages/cli/src/ui/exit-summary.ts b/packages/cli/src/ui/exit-summary.ts index 25e09b48..67db1280 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; @@ -72,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("│")}`; @@ -113,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); @@ -142,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/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/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/statusline/command-provider.ts b/packages/cli/src/ui/statusline/command-provider.ts new file mode 100644 index 00000000..c89545a4 --- /dev/null +++ b/packages/cli/src/ui/statusline/command-provider.ts @@ -0,0 +1,95 @@ +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, + newLine: config.newLine, + 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..9f4015ff --- /dev/null +++ b/packages/cli/src/ui/statusline/manager.ts @@ -0,0 +1,187 @@ +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 || + a[i]?.newLine !== b[i]?.newLine + ) { + 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; + } + const provider = await loadModuleProvider( + resolvedPath, + config.color, + providerId, + config.timeoutMs, + config.maxLength + ); + if (provider && config.newLine) { + provider.newLine = true; + } + return provider; + } + 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; + } + if (provider.newLine) { + segment.newLine = true; + } + 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..f45d6ab2 --- /dev/null +++ b/packages/cli/src/ui/statusline/module-provider.ts @@ -0,0 +1,98 @@ +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 ""; + } + 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 { + 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..6f687e61 --- /dev/null +++ b/packages/cli/src/ui/statusline/types.ts @@ -0,0 +1,41 @@ +import type { StatusLineProviderConfig } from "@vegamo/deepcode-core"; + +export type StatusSegment = { + id: string; + text: string; + color?: string; + newLine?: boolean; +}; + +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; + newLine?: boolean; + 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..3b2886cd 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 { @@ -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,8 @@ import type { UserPromptContent, } from "@vegamo/deepcode-core"; import { SessionManager } from "@vegamo/deepcode-core"; +import { getCompactPromptTokenThreshold } from "@vegamo/deepcode-core"; +import { writeStdout, writeStdoutLine } from "../../utils/stdio-helpers"; type View = "chat" | "session-list" | "undo" | "mcp-status"; @@ -53,6 +57,7 @@ const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", type AppProps = { projectRoot: string; initialPrompt?: string; + resumeSessionId?: string | true; onRestart?: () => void; }; @@ -89,12 +94,14 @@ 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 startupDoneRef = useRef(false); const processStdoutRef = useRef>(new Map()); const rawModeRef = useRef(mode); const writeRef = useRef(write); @@ -139,8 +146,8 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl 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) => { @@ -188,17 +195,20 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl * 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); + writeStdout(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] ); @@ -244,7 +254,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl setActiveAskPermissions(undefined); setPendingPermissionReply(null); setDismissedQuestionIds(new Set()); - resetStaticView([]); + await resetStaticView([]); await refreshSkills(); }, [sessionManager, resetStaticView, refreshSkills]); @@ -281,22 +291,40 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl }, [sessionManager]); writeRef.current = write; + 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); + + writeStdoutLine("\n"); + if (showCommand) { + writeStdoutLine(chalk.rgb(34, 154, 195)(" > /exit ")); + writeStdoutLine("\n"); + } + if (showSummary) { + const summary = buildExitSummaryText({ session, sessionId: activeSessionId ?? undefined }); + writeStdoutLine(summary); + writeStdoutLine("\n"); + } + if (resumeHint) { + writeStdoutLine(resumeHint); + writeStdoutLine("\n"); + } + + sessionManager.dispose(); + exit(); + }, 0); + }, + [exit, sessionManager] + ); + 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 }); - process.stdout.write("\n"); - process.stdout.write(chalk.rgb(34, 154, 195)("> /exit ")); - process.stdout.write("\n\n"); - process.stdout.write(summary); - process.stdout.write("\n\n"); - sessionManager.dispose(); - exit(); - }, 0); + handleExit({ showCommand: true, showSummary: true }); return; } if (submission.command === "new") { @@ -391,7 +419,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl [ sessionManager, pendingPermissionReply, - exit, + handleExit, onRestart, refreshSkills, refreshSessionsList, @@ -468,6 +496,10 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl [handlePrompt] ); + const handleExitShortcut = useCallback(() => { + handleExit({ showCommand: false, showSummary: false }); + }, [handleExit]); + const reloadActiveSessionView = useCallback( (sessionId: string): void => { resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); @@ -475,24 +507,11 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl [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); @@ -506,6 +525,43 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl [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 (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); + } + + // Step 2: Submit prompt if provided + if (initialPrompt && initialPrompt.trim()) { + initialPromptSubmittedRef.current = true; + handleSubmit({ + text: initialPrompt, + imageUrls: [], + selectedSkills: undefined, + }); + } + } + + void run(); + }, [handleSubmit, handleSelectSession, initialPrompt, navigateToSubView, refreshSessionsList, resumeSessionId]); + const handleDeleteSession = useCallback( async (id: string): Promise => { const isActiveSession = sessionManager.getActiveSessionId() === id; @@ -573,7 +629,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl setShowWelcome(false); setMessages([]); // Clear screen to remove stale formatted text. - process.stdout.write(ANSI_CLEAR_SCREEN); + writeStdout(ANSI_CLEAR_SCREEN); setTimeout(() => { if (nextMode === RawMode.Raw) { @@ -611,8 +667,8 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl 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); + // 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); @@ -637,6 +693,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") @@ -788,7 +899,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl ); }} - {busy || statusLine ? : null} + {(busy || statusLine) && !isExiting ? : null} {errorLine ? ( Error: {errorLine} @@ -871,7 +982,10 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl 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/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 ( - + ); 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} ); })} diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 8124d7aa..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"; @@ -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,11 +94,14 @@ 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; onInterrupt: () => void; onToggleProcessStdout?: () => void; + onExitShortcut?: () => void; }; const PROMPT_PREFIX_WIDTH = 2; @@ -123,13 +127,15 @@ export const PromptInput = React.memo(function PromptInput({ placeholder, runningProcesses, promptDraft, + statusLineSegments, + statusLineSeparator, onSubmit, onModelConfigChange, onInterrupt, onToggleProcessStdout, + onExitShortcut, onRawModeChange, }: Props): React.ReactElement { - const { exit } = useApp(); const { stdout } = useStdout(); const inputTextRef = useRef(null); const [buffer, setBuffer] = useState(EMPTY_BUFFER); @@ -352,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; @@ -839,6 +845,36 @@ export const PromptInput = React.memo(function PromptInput({ {footerText} )} + {statusLineSegments && statusLineSegments.length > 0 && ( + + {(() => { + 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/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} diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts new file mode 100644 index 00000000..3c1401af --- /dev/null +++ b/packages/cli/src/utils/package.ts @@ -0,0 +1,25 @@ +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; + +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/stdio-helpers.ts b/packages/cli/src/utils/stdio-helpers.ts new file mode 100644 index 00000000..3f117267 --- /dev/null +++ b/packages/cli/src/utils/stdio-helpers.ts @@ -0,0 +1,33 @@ +/** + * 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. + * 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"; +} diff --git a/packages/core/package.json b/packages/core/package.json index 1f924389..bac0f126 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.33", "description": "Deep Code core library — LLM session management, tool execution, and shared utilities", "license": "MIT", "type": "module", 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..5dab3b5a 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -45,6 +45,41 @@ export type PermissionSettings = { export type EnabledSkillsSettings = Record; +export type StatusLineProviderConfig = + | { + type: "command"; + id?: string; + command: string; + cwd?: string; + timeoutMs?: number; + color?: string; + newLine?: boolean; + maxLength?: number; + } + | { + type: "module"; + id?: string; + path: string; + timeoutMs?: number; + color?: string; + newLine?: boolean; + 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 +93,7 @@ export type DeepcodingSettings = { mcpServers?: Record; permissions?: PermissionSettings; enabledSkills?: EnabledSkillsSettings; + statusline?: StatusLineSettings; }; export type ResolvedDeepcodingSettings = { @@ -75,6 +111,7 @@ export type ResolvedDeepcodingSettings = { mcpServers?: Record; permissions: Required; enabledSkills: EnabledSkillsSettings; + statusline: ResolvedStatusLineSettings; }; export type ModelConfigSelection = { @@ -216,6 +253,122 @@ 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; + const newLine = value["newLine"] === true ? true : 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, + newLine, + maxLength, + }; + } + if (type === "module") { + const modulePath = trimString(value["path"]); + if (!modulePath) { + return null; + } + return { + type: "module", + id, + path: modulePath, + timeoutMs, + color, + newLine, + 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 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; + return { + enabled, + refreshMs, + separator, + providers, + }; +} + function normalizeEnv(env: DeepcodingSettings["env"]): Record { const result: Record = {}; if (!env) { @@ -393,6 +546,7 @@ export function resolveSettingsSources( mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), permissions: mergePermissions(userSettings, projectSettings), enabledSkills: mergeEnabledSkills(userSettings, projectSettings), + statusline: mergeStatusLine(userSettings, projectSettings), }; } @@ -508,9 +662,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, diff --git a/packages/core/src/tests/permissions.test.ts b/packages/core/src/tests/permissions.test.ts index a0d1f17b..fd3b676a 100644 --- a/packages/core/src/tests/permissions.test.ts +++ b/packages/core/src/tests/permissions.test.ts @@ -12,6 +12,7 @@ import { isPathInAnyDirectory, parseBashSideEffects, } from "../common/permissions"; +import type { PermissionScope, PermissionSettings } from "../settings"; const tempDirs: string[] = []; @@ -32,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"); @@ -48,41 +49,41 @@ test("evaluatePermissionScopes applies deny, ask, allow, and default mode preced }); test("evaluatePermissionScopes allows unknown when defaultMode is allowAll", () => { - const allowAllSettings = { - allow: [], - deny: [], - ask: [], - 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: [], - deny: [], - 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: [], - deny: [], - 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: [], - deny: [], - 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"]); @@ -94,10 +95,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: [ @@ -142,10 +143,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: [ { @@ -180,10 +181,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/packages/server/package.json b/packages/server/package.json new file mode 100644 index 00000000..41c2511a --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,36 @@ +{ + "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 && node -e \"require('fs').chmodSync('dist/server.js', 0o755)\"", + "test": "tsx --test src/tests/*.test.ts", + "prepublishOnly": "npm run build", + "format": "prettier --write ." + }, + "dependencies": { + "@vegamo/deepcode-core": "file:../core" + } +} 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`); 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..cec614b0 --- /dev/null +++ b/packages/server/src/server.ts @@ -0,0 +1,77 @@ +#!/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); + +type PackageInfo = { + version?: unknown; +}; + +const args = process.argv.slice(2); +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 }); +} 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"; + } +} + +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"); +} diff --git a/packages/server/src/services/auth.ts b/packages/server/src/services/auth.ts new file mode 100644 index 00000000..8a97e03b --- /dev/null +++ b/packages/server/src/services/auth.ts @@ -0,0 +1,35 @@ +/** + * 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; + } + + const tokenHeader = firstHeaderValue(request.headers["x-deepcode-token"]); + if (tokenHeader === token) { + return true; + } + + 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 ?? ""; +} 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..67b884c2 --- /dev/null +++ b/packages/server/src/services/images.ts @@ -0,0 +1,136 @@ +/** + * 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): Promise<{ ok: true; data: string[] } | { ok: false; error: string }> + */ +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 async function normalizeImageList( + projectRoot: string, + value: unknown +): 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 = await normalizeImageItem(projectRoot, item); + if (!normalized.ok) { + return normalized; + } + if (normalized.data) { + imageUrls.push(normalized.data); + } + } + return { ok: true, data: imageUrls }; +} + +async function normalizeImageItem( + projectRoot: string, + item: unknown +): Promise<{ 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" }; +} + +async function normalizeImageString( + projectRoot: string, + value: string +): Promise<{ 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); +} + +async function readImageFileAsDataUrl( + projectRoot: string, + filePath: string +): Promise<{ ok: true; data: string } | { ok: false; error: string }> { + const request = normalizeProjectFilePath(projectRoot, filePath); + if (!request.ok) { + return request; + } + let stat; + try { + stat = await fs.stat(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}` }; + } + const content = await fs.readFile(request.data.absolutePath); + return { ok: true, data: `data:${mime};base64,${content.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..2dde3e87 --- /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 async function buildPromptContent( + projectRoot: string, + body: RequestBody +): 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 = await 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..533d7182 --- /dev/null +++ b/packages/server/src/services/request-body.ts @@ -0,0 +1,65 @@ +/** + * 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 = 16 * 1024 * 1024; +const MAX_BODY_MIB = MAX_BODY_BYTES / 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; limit is ${MAX_BODY_MIB} MiB`), { 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..05d03505 --- /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 = await 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 = await 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/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); +}); 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 }); + } +}); 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; + }); +}); 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"); +}); 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/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index fd4da3ac..fc7f8118 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -1,21 +1,24 @@ { "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", "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", 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/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..9ea39656 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); @@ -255,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); +}); 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=========================================");