From a37191f8d0bcb432e7adcde09a1745aec7e8d87f Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 30 Sep 2026 16:47:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4tui=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- CLAUDE.md | 4 +- CONTRIBUTING.md | 5 +- README.md | 30 +- docs/configuration.md | 1 - package.json | 4 - packages/codingcode/package.json | 4 - packages/codingcode/scripts/bundle.mjs | 1 - packages/codingcode/src/cli.ts | 26 - .../codingcode/src/direct/agent-runtime.ts | 113 ---- packages/codingcode/src/direct/models.ts | 33 -- packages/codingcode/src/direct/sessions.ts | 177 ------ packages/codingcode/src/direct/settings.ts | 361 ------------ .../test/client/direct-types.test.ts | 92 --- .../codingcode/test/client/direct.test.ts | 253 --------- .../test/client/get-session-plan.test.ts | 32 +- packages/tui/package.json | 24 - packages/tui/src/commands/registry.ts | 57 -- packages/tui/src/components/App.tsx | 537 ------------------ packages/tui/src/components/CodeBlock.tsx | 48 -- packages/tui/src/components/InlinePanel.tsx | 222 -------- packages/tui/src/components/InputBox.tsx | 41 -- .../tui/src/components/LoadingIndicator.tsx | 13 - packages/tui/src/components/MarkdownText.tsx | 255 --------- packages/tui/src/components/MessageItem.tsx | 191 ------- packages/tui/src/components/WelcomePanel.tsx | 11 - packages/tui/src/hooks/useAgentRunner.ts | 198 ------- packages/tui/src/hooks/useTerminalSize.ts | 22 - packages/tui/src/index.tsx | 80 --- packages/tui/src/types.ts | 48 -- packages/tui/src/utils.ts | 74 --- packages/tui/test/components/App.test.tsx | 66 --- .../tui/test/components/InlinePanel.test.ts | 20 - .../tui/test/components/MarkdownText.test.ts | 65 --- .../tui/test/components/WelcomePanel.test.ts | 23 - packages/tui/test/utils.test.ts | 146 ----- packages/tui/tsconfig.json | 12 - tsconfig.json | 3 +- 38 files changed, 18 insertions(+), 3278 deletions(-) delete mode 100644 packages/codingcode/src/direct/agent-runtime.ts delete mode 100644 packages/codingcode/src/direct/models.ts delete mode 100644 packages/codingcode/src/direct/sessions.ts delete mode 100644 packages/codingcode/src/direct/settings.ts delete mode 100644 packages/codingcode/test/client/direct-types.test.ts delete mode 100644 packages/codingcode/test/client/direct.test.ts delete mode 100644 packages/tui/package.json delete mode 100644 packages/tui/src/commands/registry.ts delete mode 100644 packages/tui/src/components/App.tsx delete mode 100644 packages/tui/src/components/CodeBlock.tsx delete mode 100644 packages/tui/src/components/InlinePanel.tsx delete mode 100644 packages/tui/src/components/InputBox.tsx delete mode 100644 packages/tui/src/components/LoadingIndicator.tsx delete mode 100644 packages/tui/src/components/MarkdownText.tsx delete mode 100644 packages/tui/src/components/MessageItem.tsx delete mode 100644 packages/tui/src/components/WelcomePanel.tsx delete mode 100644 packages/tui/src/hooks/useAgentRunner.ts delete mode 100644 packages/tui/src/hooks/useTerminalSize.ts delete mode 100644 packages/tui/src/index.tsx delete mode 100644 packages/tui/src/types.ts delete mode 100644 packages/tui/src/utils.ts delete mode 100644 packages/tui/test/components/App.test.tsx delete mode 100644 packages/tui/test/components/InlinePanel.test.ts delete mode 100644 packages/tui/test/components/MarkdownText.test.ts delete mode 100644 packages/tui/test/components/WelcomePanel.test.ts delete mode 100644 packages/tui/test/utils.test.ts delete mode 100644 packages/tui/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 9ee02fb8..46574489 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,6 @@ Coding Code 是 AI 编程助手。 |---|---|---| | `@codingcode/core` | `packages/codingcode` | 核心引擎:agent loop 与全部编排能力 | | `@codingcode/infra` | `packages/infra` | 基础设施:配置加载、日志、禁用项存储 | -| `@codingcode/tui` | `packages/tui` | 终端界面(Ink + React) | | `@codingcode/desktop` | `packages/desktop` | 桌面端(Electron + React) | `packages/sdk`、`packages/web` 目前只有空 `src/`,尚无实现。 @@ -34,7 +33,6 @@ Coding Code 是 AI 编程助手。 - `rules`:全局 / 项目级规则装载 - `server`:HTTP / SSE 入口 - `client`:HTTP 客户端(`AgentClient` 的实现) -- `direct`:进程内直连端口,免 HTTP 的 runtime / sessions / settings / models 接口 - `core`:通用件(`error` / `result` / `path`),不指向任何功能模块 - `contracts`:跨领域共享契约 - `layer.ts`:组合根,全量装配 @@ -75,7 +73,7 @@ Coding Code 是 AI 编程助手。 - 禁止用户当前轮未明确要求就主动进行 reset、commit、push 等相关会影响 git 历史或者当前仓库代码的操作,仅用户显式要求进行某类操作才能进行;仅允许 `git diff`、`git log` 等无副作用的操作可以自主进行 - 禁止未在用户指示下补充测试,当开发任务完成后,给用户报告完成程度,由用户决定针对哪些部分写测试 - 禁止将工具执行细节泄漏到 agent 编排层及其他模块,agent 只依赖端口契约,不得 import 工具实现 -- 禁止将传输协议细节(HTTP / SSE)泄漏到 agent 核心及其他模块,agent 不得依赖 `server/`、`client/`、`direct/` +- 禁止将传输协议细节(HTTP / SSE)泄漏到 agent 核心及其他模块,agent 不得依赖 `server/`、`client/` - 不允许假设“这是未来需要扩展的”,所以现在就不做,应该贴合用户的实际要求 - 不允许总是有阶段性计划,分阶段完成很容易导致过程产生一堆没用的死代码 - 不许兼容、兜底旧代码 diff --git a/CLAUDE.md b/CLAUDE.md index 9ee02fb8..46574489 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,6 @@ Coding Code 是 AI 编程助手。 |---|---|---| | `@codingcode/core` | `packages/codingcode` | 核心引擎:agent loop 与全部编排能力 | | `@codingcode/infra` | `packages/infra` | 基础设施:配置加载、日志、禁用项存储 | -| `@codingcode/tui` | `packages/tui` | 终端界面(Ink + React) | | `@codingcode/desktop` | `packages/desktop` | 桌面端(Electron + React) | `packages/sdk`、`packages/web` 目前只有空 `src/`,尚无实现。 @@ -34,7 +33,6 @@ Coding Code 是 AI 编程助手。 - `rules`:全局 / 项目级规则装载 - `server`:HTTP / SSE 入口 - `client`:HTTP 客户端(`AgentClient` 的实现) -- `direct`:进程内直连端口,免 HTTP 的 runtime / sessions / settings / models 接口 - `core`:通用件(`error` / `result` / `path`),不指向任何功能模块 - `contracts`:跨领域共享契约 - `layer.ts`:组合根,全量装配 @@ -75,7 +73,7 @@ Coding Code 是 AI 编程助手。 - 禁止用户当前轮未明确要求就主动进行 reset、commit、push 等相关会影响 git 历史或者当前仓库代码的操作,仅用户显式要求进行某类操作才能进行;仅允许 `git diff`、`git log` 等无副作用的操作可以自主进行 - 禁止未在用户指示下补充测试,当开发任务完成后,给用户报告完成程度,由用户决定针对哪些部分写测试 - 禁止将工具执行细节泄漏到 agent 编排层及其他模块,agent 只依赖端口契约,不得 import 工具实现 -- 禁止将传输协议细节(HTTP / SSE)泄漏到 agent 核心及其他模块,agent 不得依赖 `server/`、`client/`、`direct/` +- 禁止将传输协议细节(HTTP / SSE)泄漏到 agent 核心及其他模块,agent 不得依赖 `server/`、`client/` - 不允许假设“这是未来需要扩展的”,所以现在就不做,应该贴合用户的实际要求 - 不允许总是有阶段性计划,分阶段完成很容易导致过程产生一堆没用的死代码 - 不许兼容、兜底旧代码 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05a980e4..77c94078 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,16 +39,13 @@ coding-code/ │ │ ├── subagent/ # 子智能体加载和注册 │ │ ├── scheduler/ # 调度服务 │ │ ├── rules/ # 规则注入 -│ │ ├── client/ # 客户端(HTTP / Direct / SSE) +│ │ ├── client/ # 客户端(HTTP / SSE) │ │ ├── core/ # 核心工具类型和路径 │ │ ├── runtime/ # 项目运行时 │ │ ├── sandbox/ # 沙箱(stub,预留) │ │ ├── server/ # Hono HTTP 服务 + SSE │ │ ├── cli.ts # CLI 入口 │ │ └── layer.ts # Effect Layer 入口 -│ ├── tui/src/ # @codingcode/tui — Ink 终端 UI -│ │ ├── components/ # App, InputBox, MessageItem 等 -│ │ └── hooks/ # useAgentRunner, useTerminalSize │ ├── desktop/ # @codingcode/desktop — Electron 桌面应用 │ │ ├── electron/ # 主进程(IPC、文件服务、Git 服务) │ │ └── src/ # React 前端(Agent UI、设置面板) diff --git a/README.md b/README.md index 30bc5ad6..565d5cbe 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/) -终端原生的 AI 编程助手。核心引擎纯手写 ReAct 循环,通过 HTTP 服务化对外暴露,TUI / Desktop / SDK 等所有端共享同一份编排逻辑。没有黑盒,所有行为都可定制。 +终端原生的 AI 编程助手。核心引擎纯手写 ReAct 循环,通过 HTTP 服务化对外暴露,Desktop / SDK 等所有端共享同一份编排逻辑。没有黑盒,所有行为都可定制。 @@ -43,29 +43,24 @@ pnpm install # 2. 配置 API Key export DEEPSEEK_API_KEY=sk-xxx -# 3. 启动(server + TUI) +# 3. 启动 pnpm start ``` -启动成功后,终端会显示交互式 TUI 界面。 - -### 其他启动方式 - -```bash -pnpm start serve # 仅启动 HTTP server(供 Web / SDK 调用) -pnpm start tui # 仅启动 TUI(连接已有 server) -``` +启动成功后,HTTP server 开始监听并打印 `CODINGCODE_SERVER_READY:`,可用 Desktop 或 SDK 接入。 ### SDK 调用示例 ```typescript -import { createHttpClient } from '@codingcode/core/client/http'; +import { createHttpClients } from '@codingcode/core/client'; -const client = await createHttpClient('http://localhost:8080'); +const clients = createHttpClients('http://localhost:8080'); -for await (const chunk of client.sendMessage('帮我写一个快排')) { - if (chunk.type === 'text') { - process.stdout.write(chunk.text); +for await (const frame of clients.agent.sendMessage('帮我写一个快排', { + cwd: process.cwd(), +})) { + if (frame.family === 'event' && frame.event.type === 'text_delta') { + process.stdout.write(frame.event.text); } } ``` @@ -77,7 +72,7 @@ for await (const chunk of client.sendMessage('帮我写一个快排')) { ``` ┌──────────────────────────────────────────────────────────┐ │ 客户端层 │ -│ @codingcode/tui (Ink) · @codingcode/desktop (Electron) │ +│ @codingcode/desktop (Electron) │ └──────────────────────────┬───────────────────────────────┘ │ HTTP / SSE(AgentClient 接口) ┌──────────────────────────┴───────────────────────────────┐ @@ -126,7 +121,7 @@ for await (const chunk of client.sendMessage('帮我写一个快排')) { } ``` -`driver` 支持 `"deepseek"`(原生 SDK)、`"openai"`(OpenAI 兼容 API)和 `"gemini"`(Google Gemini)。运行时可通过 `/model` 命令切换。 +`driver` 支持 `"deepseek"`(原生 SDK)、`"openai"`(OpenAI 兼容 API)和 `"gemini"`(Google Gemini)。运行时可在客户端中切换模型。 --- @@ -168,7 +163,6 @@ for await (const chunk of client.sendMessage('帮我写一个快排')) { | DI / 错误追踪 | Effect TS 3.x | | LLM SDK | Vercel AI SDK v6 + @ai-sdk/deepseek + @ai-sdk/openai | | HTTP 框架 | Hono 4.x | -| TUI | Ink 7.x + React 19 | | Desktop | Electron 35 + React 19 + Zustand 5 | | MCP | @modelcontextprotocol/sdk 1.29.x | | 校验 | Zod 4.x | diff --git a/docs/configuration.md b/docs/configuration.md index 561d1faf..4ddbed5b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -116,7 +116,6 @@ memory: ### 运行时切换 -- TUI 中输入 `/model` 命令可切换模型 - 通过 API `POST /api/models` 切换 --- diff --git a/package.json b/package.json index 265005ac..bdd6f9ca 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "workspaces": [ "packages/infra", "packages/codingcode", - "packages/tui", "packages/desktop" ], "scripts": { @@ -34,9 +33,6 @@ "effect": "^3.21.2", "globby": "^14.1.0", "hono": "^4.12.19", - "ink": "^7.0.3", - "ink-spinner": "^5.0.0", - "ink-text-input": "^6.0.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "react": "^19.2.6", diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index 47a02180..f79d9fac 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -10,10 +10,6 @@ ".": "./src/layer.ts", "./client": "./src/client/http/index.ts", "./server": "./src/server/index.ts", - "./direct/agent-runtime": "./src/direct/agent-runtime.ts", - "./direct/sessions": "./src/direct/sessions.ts", - "./direct/settings": "./src/direct/settings.ts", - "./direct/models": "./src/direct/models.ts", "./approval/types": "./src/approval/types.ts", "./agent/profile": "./src/agent/profile.ts", "./contracts/frame": "./src/contracts/frame.ts", diff --git a/packages/codingcode/scripts/bundle.mjs b/packages/codingcode/scripts/bundle.mjs index 28301944..661077cf 100644 --- a/packages/codingcode/scripts/bundle.mjs +++ b/packages/codingcode/scripts/bundle.mjs @@ -14,7 +14,6 @@ await build({ outfile: resolve(root, 'dist/cli.bundle.js'), // pino-pretty 仅在开发模式使用,生产模式不会加载 external: ['pino-pretty'], - // 动态 import 的 TUI 路径无法静态解析,保持原样 splitting: false, sourcemap: true, minify: false, diff --git a/packages/codingcode/src/cli.ts b/packages/codingcode/src/cli.ts index e37fbaf5..1361e744 100644 --- a/packages/codingcode/src/cli.ts +++ b/packages/codingcode/src/cli.ts @@ -1,6 +1,5 @@ import { Effect } from 'effect'; import { serve } from '@hono/node-server'; -import { LLMFactoryService } from './llm/port.js'; import { createServer } from './server/index.js'; import { createAppRuntime } from './layer.js'; import { loadConfig, ensureUserConfig } from '@codingcode/infra/config'; @@ -10,50 +9,25 @@ import { AgentError } from './core/error.js'; import { SchedulerService } from './scheduler/port.js'; async function main() { - const args = process.argv.slice(2); ensureUserConfig(); ensureTempCwd(); const config = loadConfig(); - const serveOnly = args.includes('serve'); - const tuiOnly = args.includes('tui'); const basePort = config.server.port; const rt = createAppRuntime(); const program = Effect.gen(function* () { const port = yield* Effect.tryPromise(() => findAvailablePort(basePort)); - const llmFactory = yield* LLMFactoryService; // Initialize scheduler with the shared runtime const scheduler = yield* SchedulerService; scheduler.setRuntime(rt); scheduler.initialize(); - if (tuiOnly) { - const tuiPath = '../../tui/src/index.js'; - const { runTui, createTuiClientFromFacades } = yield* Effect.tryPromise( - () => import(tuiPath) - ); - const llm = yield* llmFactory.getLLMClient(); - const client = createTuiClientFromFacades(llm, rt); - runTui({ client }); - return; - } - const app = yield* Effect.tryPromise(() => createServer(rt)); serve({ fetch: app.fetch, port }); console.log(`CODINGCODE_SERVER_READY:${port}`); - - if (!serveOnly) { - const tuiPath = '../../tui/src/index.js'; - const { runTui, createTuiClientFromFacades } = yield* Effect.tryPromise( - () => import(tuiPath) - ); - const llm = yield* llmFactory.getLLMClient(); - const client = createTuiClientFromFacades(llm, rt); - runTui({ client }); - } }); const result = await rt.runPromise( diff --git a/packages/codingcode/src/direct/agent-runtime.ts b/packages/codingcode/src/direct/agent-runtime.ts deleted file mode 100644 index 30986f13..00000000 --- a/packages/codingcode/src/direct/agent-runtime.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Effect } from 'effect'; -import { AgentService } from '../agent/port.js'; -import { ApprovalWaitService } from '../approval/wait-port.js'; -import { parseApprovalResponse } from '../approval/confirmation.js'; -import { ContextService } from '../context/port.js'; -import { SessionService } from '../session/port.js'; -import { computePaths } from '../core/path.js'; -import type { AgentRuntimeClient } from '../client/contracts.js'; -import type { FrameBody } from '../contracts/frame.js'; -import { createFrameAssembler } from '../contracts/frame-io.js'; -import type { AppRuntime } from '../layer.js'; -import type { LLMClient } from '../contracts/provider.js'; - -export function createDirectAgentClient(llm: LLMClient, rt: AppRuntime): AgentRuntimeClient { - return { - async *sendMessage(input, { sessionId, cwd }) { - const runOpts: { cwd: string; activeProfile?: 'build'; permissionMode?: 'default' } = { cwd }; - if (!sessionId) { - runOpts.activeProfile = 'build'; - runOpts.permissionMode = 'default'; - } - const { stream: agentGen, sessionId: resolvedSessionId } = await rt.runPromise( - Effect.gen(function* () { - const agent = yield* AgentService; - return yield* agent.runTurn(input, { sessionId: sessionId || undefined, ...runOpts }); - }) - ); - - const assembler = createFrameAssembler({ sessionId: resolvedSessionId }); - - let notifyApproval: ((body: FrameBody) => void) | null = null; - const waitService = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalWaitService; - }) - ); - Effect.runSync( - waitService.registerEmitter( - resolvedSessionId, - (id: string, tool: string, args: Record) => { - notifyApproval?.({ family: 'event', event: { type: 'approval_request', id, tool, args } }); - } - ) - ); - - try { - let pending = agentGen.next(); - let currentApprovalPromise = new Promise((resolve) => { - notifyApproval = resolve; - }); - - while (true) { - const approvalPromise = currentApprovalPromise; - const winner = await Promise.race([ - pending.then((c): { tag: 'body'; value: IteratorResult } => ({ - tag: 'body', - value: c, - })), - approvalPromise.then((body): { tag: 'approval'; value: FrameBody } => ({ - tag: 'approval', - value: body, - })), - ]); - - if (winner.tag === 'body') { - if (winner.value.done) break; - yield assembler.stamp(winner.value.value); - currentApprovalPromise = new Promise((resolve) => { - notifyApproval = resolve; - }); - pending = agentGen.next(); - } else { - yield assembler.stamp(winner.value); - currentApprovalPromise = new Promise((resolve) => { - notifyApproval = resolve; - }); - } - } - } finally { - Effect.runSync(waitService.unregisterEmitter(resolvedSessionId)); - } - }, - - async sendApprovalResponse({ sessionId, approvalId, response }) { - const result = parseApprovalResponse(response); - await rt.runPromise( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - return yield* svc.resolveConfirm(approvalId, sessionId, result); - }) - ); - }, - - async compact({ sessionId, cwd }) { - await rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - const context = yield* ContextService; - const state = yield* session.load(cwd, sessionId); - return yield* Effect.promise(() => - context.compactWithLLM( - computePaths(state.cwd, state.sessionId, state.parentSessionId).transcriptPath, - llm.modelInfo.maxTokens, - null - ) - ); - }) - ); - }, - }; -} - -export type { AgentRuntimeClient }; diff --git a/packages/codingcode/src/direct/models.ts b/packages/codingcode/src/direct/models.ts deleted file mode 100644 index 55a1fbb8..00000000 --- a/packages/codingcode/src/direct/models.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Effect } from 'effect'; -import { LLMFactoryService } from '../llm/port.js'; -import type { ModelClient } from '../client/contracts.js'; -import type { AppRuntime } from '../layer.js'; - -export function createDirectModelClient(rt: AppRuntime): ModelClient { - return { - async listModels() { - return rt.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - const modelsResult = yield* Effect.either(factory.listModels()); - if (modelsResult._tag === 'Left') throw modelsResult.left; - const activeResult = yield* Effect.either(factory.getActiveEntry()); - return { - models: modelsResult.right, - activeId: activeResult._tag === 'Right' ? activeResult.right.id : null, - }; - }) - ); - }, - - async switchModel({ id }) { - return rt.runPromise( - Effect.gen(function* () { - const factory = yield* LLMFactoryService; - const switchResult = yield* Effect.either(factory.switchModel(id)); - if (switchResult._tag === 'Left') throw switchResult.left; - }) - ); - }, - }; -} diff --git a/packages/codingcode/src/direct/sessions.ts b/packages/codingcode/src/direct/sessions.ts deleted file mode 100644 index d854f085..00000000 --- a/packages/codingcode/src/direct/sessions.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { Effect } from 'effect'; -import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; -import { join } from 'path'; -import { SessionService } from '../session/port.js'; -import { encodeProjectPath, getProjectBaseDir } from '../core/path.js'; -import type { PermissionMode } from '../contracts/permission.js'; -import { AVAILABLE_PROFILES } from '../agent/profile.js'; -import type { SessionClient } from '../client/contracts.js'; -import type { AppRuntime } from '../layer.js'; - -export function createDirectSessionClient(rt: AppRuntime): SessionClient { - return { - async createSession({ cwd, activeProfile, permissionMode, model }) { - return rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.create(cwd, { - model, - activeProfile, - permissionMode, - }); - return { sessionId: state.sessionId }; - }) - ); - }, - - async resumeSession({ sessionId, cwd }) { - return rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - return yield* session.readUITurns(sessionId, cwd); - }) - ); - }, - - async listSessions({ cwd }) { - return rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - return yield* session.listSessions(cwd); - }) - ); - }, - - async getSessionHistory({ sessionId, cwd }) { - return rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - return yield* session.readUITurns(sessionId, cwd); - }) - ); - }, - - async deleteSession({ sessionId, cwd }) { - await rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - yield* session.deleteSession(sessionId, cwd); - }) - ); - }, - - async getSessionProfile({ sessionId, cwd }) { - return rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - return { - activeProfile: state.activeProfile, - permissionMode: state.permissionMode, - cwd, - available: AVAILABLE_PROFILES, - }; - }) - ); - }, - - async setSessionProfile({ sessionId, cwd, activeProfile }) { - return rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - yield* session.setActiveProfile(cwd, sessionId, activeProfile); - const state = yield* session.load(cwd, sessionId); - return { activeProfile: state.activeProfile, permissionMode: state.permissionMode }; - }) - ); - }, - - async getSessionPermissionMode({ sessionId, cwd }): Promise { - const mode = await rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - return state.permissionMode; - }) - ); - return mode as PermissionMode; - }, - - async setSessionPermissionMode({ sessionId, cwd, mode }) { - return rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - yield* session.setPermissionMode(cwd, sessionId, mode); - }) - ); - }, - - async getSessionPlan({ cwd }) { - const planDir = join(getProjectBaseDir(), encodeProjectPath(cwd)); - if (!existsSync(planDir)) { - return { content: '', path: '', directory: planDir, exists: false }; - } - let latest: { path: string; mtime: number } | null = null; - for (const name of readdirSync(planDir)) { - if (!name.endsWith('.md')) continue; - const full = join(planDir, name); - const mtime = statSync(full).mtimeMs; - if (latest === null || mtime > latest.mtime) { - latest = { path: full, mtime }; - } - } - if (latest === null) { - return { content: '', path: '', directory: planDir, exists: false }; - } - const content = readFileSync(latest.path, 'utf8'); - return { content, path: latest.path, directory: planDir, exists: true }; - }, - - async getCheckpointDiff() { - return { turnId: 0, files: [] }; - }, - async revertCheckpointFiles() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - }; - }, - async previewRollbackDiff() { - return { throughTurnId: 0, affectedTurns: [], diff: '' }; - }, - async rollbackCodeToTurn() { - return { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - }; - }, - async rollbackContext() { - return { turns: [] }; - }, - async rollbackBothToTurn() { - return { - turns: [], - codeResult: { - reverted: false, - throughTurnId: 0, - affectedTurns: [], - selectedFiles: [], - }, - }; - }, - async forkSession({ sessionId, cwd, atTurnId }) { - const newSessionId = await rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(cwd, sessionId); - return yield* session.forkSession(state, atTurnId ?? 0); - }) - ); - return { sessionId: newSessionId, turns: [] }; - }, - }; -} diff --git a/packages/codingcode/src/direct/settings.ts b/packages/codingcode/src/direct/settings.ts deleted file mode 100644 index f0c74618..00000000 --- a/packages/codingcode/src/direct/settings.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { Effect } from 'effect'; -import { McpService } from '../mcp/port.js'; -import type { McpServerConfig, McpStatus } from '../contracts/mcp.js'; -import { SkillService } from '../skills/port.js'; -import type { PermissionMode } from '../contracts/permission.js'; -import type { UserHookConfig } from '../contracts/hooks.js'; -import { isGlobalCwd, resolveCwd } from '../core/path.js'; -import { - loadMcpConfig, - writeMcpConfig, - loadGlobalMcpConfig, - writeGlobalMcpConfig, - setGlobalMcpServerEnabled, - setProjectMcpServerEnabled, -} from '../mcp/config.js'; -import { - loadHookConfigs, - writeHookConfigs, - loadGlobalHookConfigs, - writeGlobalHookConfigs, - resolveHookConfigs, - setGlobalHookEnabled, - setProjectHookEnabled, -} from '../hooks/config.js'; -import { getMemoryConfig } from '../memory/config.js'; -import { MemoryService } from '../memory/port.js'; -import { AlreadyExistsError, NotFoundError } from '../contracts/error.js'; -import { - loadConfig, - updateMemoryModel, - updateContextCompactionModel, -} from '@codingcode/infra/config'; -import type { AppRuntime } from '../layer.js'; -import { SessionService } from '../session/port.js'; -import type { SettingsClient } from '../client/contracts.js'; - -// ---- Helpers with validation ---- - -function mcpCreateServer(cwd: string, server: McpServerConfig): void { - if (isGlobalCwd(cwd)) { - const servers = loadGlobalMcpConfig(); - if (servers.some((s) => s.name === server.name)) { - throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); - } - writeGlobalMcpConfig([...servers, server]); - return; - } - const servers = loadMcpConfig(cwd); - if (servers.some((s) => s.name === server.name)) { - throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); - } - servers.push(server); - writeMcpConfig(cwd, servers); -} - -function mcpUpdateServer(cwd: string, name: string, server: McpServerConfig): void { - if (isGlobalCwd(cwd)) { - const servers = loadGlobalMcpConfig(); - const idx = servers.findIndex((s) => s.name === name); - if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); - if (server.name !== name && servers.some((s) => s.name === server.name)) { - throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); - } - servers[idx] = server; - writeGlobalMcpConfig(servers); - return; - } - const servers = loadMcpConfig(cwd); - const idx = servers.findIndex((s) => s.name === name); - if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); - if (server.name !== name && servers.some((s) => s.name === server.name)) { - throw new AlreadyExistsError(`MCP server '${server.name}' already exists`); - } - servers[idx] = server; - writeMcpConfig(cwd, servers); -} - -function mcpDeleteServer(cwd: string, name: string): void { - if (isGlobalCwd(cwd)) { - const servers = loadGlobalMcpConfig().filter((s) => s.name !== name); - writeGlobalMcpConfig(servers); - return; - } - const servers = loadMcpConfig(cwd); - if (!servers.some((s) => s.name === name)) { - throw new NotFoundError(`MCP server '${name}' not found in project config`); - } - writeMcpConfig( - cwd, - servers.filter((s) => s.name !== name) - ); -} - -function hooksList( - cwd: string -): Array { - if (isGlobalCwd(cwd)) { - return loadGlobalHookConfigs().map((h) => ({ - ...h, - enabled: h.enabled !== false, - source: 'global' as const, - })); - } - const globalHooks = loadGlobalHookConfigs(); - const projectHooks = loadHookConfigs(cwd); - const globalNames = new Set(globalHooks.map((h) => h.name)); - const projectNames = new Set(projectHooks.map((h) => h.name)); - const merged = resolveHookConfigs(cwd); - return merged.map((h) => { - const isFromProject = projectNames.has(h.name); - const isFromGlobal = globalNames.has(h.name); - const hasProjectOverride = isFromProject && isFromGlobal; - return { - ...h, - enabled: h.enabled !== false, - source: (isFromProject ? 'project' : 'global') as 'global' | 'project', - hasProjectOverride, - }; - }); -} - -function hooksCreate(cwd: string, hook: UserHookConfig): void { - if (isGlobalCwd(cwd)) { - const hooks = loadGlobalHookConfigs(); - if (hooks.some((h) => h.name === hook.name)) { - throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); - } - writeGlobalHookConfigs([...hooks, hook]); - return; - } - const hooks = loadHookConfigs(cwd); - if (hooks.some((h) => h.name === hook.name)) { - throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); - } - hooks.push(hook); - writeHookConfigs(cwd, hooks); -} - -function hooksUpdate(cwd: string, name: string, hook: UserHookConfig): void { - if (isGlobalCwd(cwd)) { - const hooks = loadGlobalHookConfigs(); - const idx = hooks.findIndex((h) => h.name === name); - if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); - if (hook.name !== name && hooks.some((h) => h.name === hook.name)) { - throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); - } - hooks[idx] = hook; - writeGlobalHookConfigs(hooks); - return; - } - const hooks = loadHookConfigs(cwd); - const idx = hooks.findIndex((h) => h.name === name); - if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); - if (hook.name !== name && hooks.some((h) => h.name === hook.name)) { - throw new AlreadyExistsError(`Hook '${hook.name}' already exists`); - } - hooks[idx] = hook; - writeHookConfigs(cwd, hooks); -} - -function hooksDelete(cwd: string, name: string): void { - if (isGlobalCwd(cwd)) { - const hooks = loadGlobalHookConfigs().filter((h) => h.name !== name); - writeGlobalHookConfigs(hooks); - return; - } - const hooks = loadHookConfigs(cwd); - if (!hooks.some((h) => h.name === name)) { - throw new NotFoundError(`Hook '${name}' not found in project config`); - } - writeHookConfigs( - cwd, - hooks.filter((h) => h.name !== name) - ); -} - -function hooksSetEnabled(cwd: string, name: string, enabled: boolean): void { - if (isGlobalCwd(cwd)) { - setGlobalHookEnabled(name, enabled); - return; - } - setProjectHookEnabled(resolveCwd(cwd), name, enabled); -} - -export function createDirectSettingsClient(rt: AppRuntime): SettingsClient { - return { - async getMemoryEnabled() { - return rt.runPromise( - Effect.gen(function* () { - const m = yield* MemoryService; - return m.getMemoryEnabled(); - }) - ); - }, - - async getMemoryConfig() { - const cfg = getMemoryConfig(); - return { enabled: cfg.enabled, model: cfg.model }; - }, - - async setMemoryEnabled(enabled) { - await rt.runPromise( - Effect.gen(function* () { - const m = yield* MemoryService; - m.setMemoryEnabled(enabled); - }) - ); - }, - - async setMemoryModel(model) { - updateMemoryModel(model); - return { model }; - }, - - async getAgentConfig() { - const cfg = loadConfig(); - return { maxSteps: cfg.maxSteps, maxStopContinuations: cfg.maxStopContinuations }; - }, - - async setCompactionModel(compactionModel) { - updateContextCompactionModel(compactionModel); - return { compactionModel }; - }, - - async getMcpStatus({ cwd }) { - const projectCwd = resolveCwd(cwd); - const runtime = await rt.runPromise( - Effect.gen(function* () { - const mcp = yield* McpService; - return yield* mcp.status(projectCwd); - }) - ); - const runtimeByName = new Map(runtime.map((r) => [r.name, r])); - if (isGlobalCwd(cwd)) { - return loadGlobalMcpConfig().map((s) => ({ - ...runtimeByName.get(s.name), - name: s.name, - enabled: s.enabled !== false, - source: 'global' as const, - })) as McpStatus[]; - } - const globalServers = loadGlobalMcpConfig(); - const projectServers = loadMcpConfig(projectCwd); - const globalNames = new Set(globalServers.map((s) => s.name)); - const seen = new Set(); - const result: Array< - McpStatus & { - enabled: boolean; - source: 'global' | 'project'; - hasProjectOverride?: boolean; - } - > = []; - for (const s of projectServers) { - seen.add(s.name); - const isFromGlobal = globalNames.has(s.name); - const r = runtimeByName.get(s.name); - result.push({ - ...(r ?? { - name: s.name, - transport: 'stdio' as const, - toolCount: 0, - }), - name: s.name, - enabled: s.enabled !== false, - source: 'project', - hasProjectOverride: isFromGlobal, - }); - } - for (const s of globalServers) { - if (seen.has(s.name)) continue; - const r = runtimeByName.get(s.name); - result.push({ - ...(r ?? { - name: s.name, - transport: 'stdio' as const, - toolCount: 0, - }), - name: s.name, - enabled: s.enabled !== false, - source: 'global', - }); - } - return result as McpStatus[]; - }, - - async setMcpEnabled({ name, enabled, cwd }) { - if (isGlobalCwd(cwd)) { - setGlobalMcpServerEnabled(name, enabled); - } else { - setProjectMcpServerEnabled(resolveCwd(cwd), name, enabled); - } - }, - - async createMcpServer({ cwd, server }) { - mcpCreateServer(cwd, server); - }, - - async updateMcpServer({ cwd, name, server }) { - mcpUpdateServer(cwd, name, server); - }, - - async deleteMcpServer({ cwd, name }) { - mcpDeleteServer(cwd, name); - }, - - async listSkills({ cwd }) { - return rt.runPromise( - Effect.gen(function* () { - const skill = yield* SkillService; - return yield* skill.getAll(resolveCwd(cwd)); - }) - ); - }, - - async listHooks({ cwd }) { - return hooksList(cwd) as unknown as UserHookConfig[]; - }, - - async createHook({ cwd, hook }) { - hooksCreate(cwd, hook); - }, - - async updateHook({ cwd, name, hook }) { - hooksUpdate(cwd, name, hook); - }, - - async deleteHook({ cwd, name }) { - hooksDelete(cwd, name); - }, - - async setHookEnabled({ cwd, name, enabled }) { - hooksSetEnabled(cwd, name, enabled); - }, - - async getGlobalPermissionMode(input: { - sessionId: string; - cwd: string; - }): Promise { - return rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - const state = yield* session.load(input.cwd, input.sessionId); - return state.permissionMode; - }) - ); - }, - - async setGlobalPermissionMode(input: { - sessionId: string; - cwd: string; - mode: PermissionMode; - }): Promise { - await rt.runPromise( - Effect.gen(function* () { - const session = yield* SessionService; - yield* session.setPermissionMode(input.cwd, input.sessionId, input.mode); - }) - ); - }, - }; -} diff --git a/packages/codingcode/test/client/direct-types.test.ts b/packages/codingcode/test/client/direct-types.test.ts deleted file mode 100644 index a9636cfd..00000000 --- a/packages/codingcode/test/client/direct-types.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; - -import { createDirectAgentClient } from '../../src/direct/agent-runtime.js'; -import { createDirectSessionClient } from '../../src/direct/sessions.js'; -import { createDirectModelClient } from '../../src/direct/models.js'; -import { createDirectSettingsClient } from '../../src/direct/settings.js'; -import type { AppRuntime } from '../../src/layer.js'; -import type { LLMClient } from '../../src/contracts/provider.js'; -import { ApprovalWaitService } from '../../src/approval/wait-port.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; -import { AgentError } from '../../src/core/error.js'; -import { ApprovalWaitLayer } from '../../src/approval/wait.js'; - -type AssertNotAny = 0 extends 1 & T ? never : T; - -type _AppRuntimeNotAny = AssertNotAny; -type _LLMClientNotAny = AssertNotAny; - -type _AgentParams = Parameters; -type _LlmParamNotAny = AssertNotAny<_AgentParams[0]>; -type _RtParamNotAny = AssertNotAny<_AgentParams[1]>; - -type _SessionParams = Parameters; -type _SessionRtNotAny = AssertNotAny<_SessionParams[0]>; - -const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { - getLLMClient: () => Effect.succeed(null), - listModels: () => Effect.succeed([]), - switchModel: () => Effect.fail(new AgentError('CONFIG_INVALID', 'not found')), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new AgentError('CONFIG_INVALID', 'No active model')), - createClient: () => Effect.succeed(null), -} as any); - -const TestLayer = Layer.mergeAll( - ApprovalWaitLayer, - MockLLMFactoryLayer -); - -const rt = ManagedRuntime.make(TestLayer); - -const noopLlm: LLMClient = { - completeStream: () => - (async function* () { - yield { type: 'end' as const }; - })(), - complete: () => Effect.succeed({ content: '' }), - modelInfo: { - provider: 'test', - model: 'test-model', - maxTokens: 128000, - supportsToolCalling: true, - supportsStreaming: true, - }, -}; - -describe('type replacements: AppRuntime and LLMClient', () => { - it('createDirectAgentClient accepts LLMClient and ManagedRuntime', () => { - const agentClient = createDirectAgentClient(noopLlm, rt); - expect(agentClient).toBeDefined(); - expect(typeof agentClient.sendMessage).toBe('function'); - }); - - it('createDirectSessionClient accepts ManagedRuntime', () => { - const sessionClient = createDirectSessionClient(rt); - expect(sessionClient).toBeDefined(); - expect(typeof sessionClient.listSessions).toBe('function'); - }); - - it('createDirectModelClient accepts ManagedRuntime', () => { - const modelClient = createDirectModelClient(rt); - expect(modelClient).toBeDefined(); - expect(typeof modelClient.listModels).toBe('function'); - }); - - it('createDirectSettingsClient accepts ManagedRuntime', () => { - const settingsClient = createDirectSettingsClient(rt); - expect(settingsClient).toBeDefined(); - expect(typeof settingsClient.getMemoryEnabled).toBe('function'); - }); - - it('waitService from runtime has registerEmitter and unregisterEmitter', async () => { - const waitService = await rt.runPromise( - Effect.gen(function* () { - return yield* ApprovalWaitService; - }) - ); - expect(typeof waitService.registerEmitter).toBe('function'); - expect(typeof waitService.unregisterEmitter).toBe('function'); - }); -}); diff --git a/packages/codingcode/test/client/direct.test.ts b/packages/codingcode/test/client/direct.test.ts deleted file mode 100644 index 275025e5..00000000 --- a/packages/codingcode/test/client/direct.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { Effect, Layer, ManagedRuntime } from 'effect'; - -import { createDirectModelClient } from '../../src/direct/models.js'; -import { ApprovalWaitService } from '../../src/approval/wait-port.js'; -import { AgentError } from '../../src/core/error.js'; -import { LLMFactoryService } from '../../src/llm/port.js'; -import { ApprovalWaitLayer } from '../../src/approval/wait.js'; - -const MockLLMFactoryLayer = Layer.succeed(LLMFactoryService, { - getLLMClient: () => Effect.succeed(null), - listModels: () => - Effect.succeed([ - { - id: 'test-model@TEST_KEY', - provider: 'test', - driver: 'openai', - name: 'Test Model', - model: 'test-model', - base_url: 'http://localhost', - api_key_env: 'TEST_KEY', - context_window: 128000, - }, - ]), - switchModel: (id: string) => - Effect.fail(new AgentError('CONFIG_INVALID', `Model "${id}" not found. Use /models to list.`)), - findModel: () => Effect.succeed(null), - getActiveEntry: () => Effect.fail(new AgentError('CONFIG_INVALID', 'No active model configured')), - createClient: () => Effect.succeed(null), -} as any); - -const TestLayer = Layer.mergeAll( - ApprovalWaitLayer, - MockLLMFactoryLayer -); - -const rt = ManagedRuntime.make(TestLayer); - -describe('createDirectModelClient operations', () => { - it('lists models from the local model catalog without HTTP', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch'); - const client = createDirectModelClient(rt); - - const result = await client.listModels(); - - expect(fetchSpy).not.toHaveBeenCalled(); - expect(result.models.length).toBeGreaterThan(0); - // activeId is null when no activeModel is set in config - expect(result.activeId === null || typeof result.activeId === 'string').toBe(true); - - fetchSpy.mockRestore(); - }); - - it('rejects unknown model switches without contacting server', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch'); - const client = createDirectModelClient(rt); - - await expect(client.switchModel({ id: 'missing-model@MISSING_KEY' })).rejects.toThrow( - 'not found' - ); - - expect(fetchSpy).not.toHaveBeenCalled(); - fetchSpy.mockRestore(); - }); -}); - -describe('approval buffering - race condition fix', () => { - const run = (eff: Effect.Effect): Promise => rt.runPromise(eff); - - it('buffers approval request when notify is null', async () => { - const sessionId = 'buffer-' + Math.random().toString(36).slice(2); - let notify: ((req: any) => void) | null = null; - let bufferedApproval: any = null; - const delivered: any[] = []; - - await run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - yield* svc.registerEmitter( - sessionId, - (id: string, tool: string, args: Record) => { - const req = { type: 'approval_request' as const, id, tool, args }; - if (notify) { - const cb = notify; - notify = null; - cb(req); - } else { - bufferedApproval = req; - } - } - ); - }) - ); - - try { - // Simulate the race loop: chunk arrives, notify set to null - notify = null; - - // Fire approval request while notify is null - await run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - yield* svc.emitApprovalRequest(sessionId, 'apr-1', 'bash', { command: 'ls' }); - }) - ); - - // Should have been buffered - expect(bufferedApproval).not.toBeNull(); - expect(bufferedApproval.id).toBe('apr-1'); - expect(bufferedApproval.tool).toBe('bash'); - - // Consume buffer - const req = bufferedApproval; - bufferedApproval = null; - delivered.push(req); - - // Now set notify and fire another request - notify = (r) => delivered.push(r); - await run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - yield* svc.emitApprovalRequest(sessionId, 'apr-2', 'bash', { command: 'pwd' }); - }) - ); - - // notify should have been consumed (set to null by callback) - expect(delivered).toHaveLength(2); - expect(delivered[0].id).toBe('apr-1'); - expect(delivered[1].id).toBe('apr-2'); - expect(bufferedApproval).toBeNull(); - expect(notify).toBeNull(); - } finally { - await run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - yield* svc.unregisterEmitter(sessionId); - }) - ); - } - }); - - it('delivers approval immediately when notify is active (no buffering)', async () => { - const sessionId = 'direct-' + Math.random().toString(36).slice(2); - let notify: ((req: any) => void) | null = null; - let bufferedApproval: any = null; - const delivered: any[] = []; - - await run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - yield* svc.registerEmitter( - sessionId, - (id: string, tool: string, args: Record) => { - const req = { type: 'approval_request' as const, id, tool, args }; - if (notify) { - const cb = notify; - notify = null; - cb(req); - } else { - bufferedApproval = req; - } - } - ); - }) - ); - - try { - notify = (r) => delivered.push(r); - - await run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - yield* svc.emitApprovalRequest(sessionId, 'apr-1', 'bash', { command: 'ls' }); - }) - ); - - expect(delivered).toHaveLength(1); - expect(delivered[0].id).toBe('apr-1'); - expect(notify).toBeNull(); // callback consumed notify - expect(bufferedApproval).toBeNull(); - } finally { - await run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - yield* svc.unregisterEmitter(sessionId); - }) - ); - } - }); - - it('handles multiple approval requests arriving while notify is null', async () => { - const sessionId = 'multi-' + Math.random().toString(36).slice(2); - let notify: ((req: any) => void) | null = null; - let bufferedApproval: any = null; - - await run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - yield* svc.registerEmitter( - sessionId, - (id: string, tool: string, args: Record) => { - const req = { type: 'approval_request' as const, id, tool, args }; - if (notify) { - const cb = notify; - notify = null; - cb(req); - } else { - bufferedApproval = req; - } - } - ); - }) - ); - - try { - notify = null; - - await run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - yield* svc.emitApprovalRequest(sessionId, 'apr-1', 'bash', { command: 'a' }); - }) - ); - - expect(bufferedApproval).not.toBeNull(); - expect(bufferedApproval.id).toBe('apr-1'); - - // Second request also arrives while notify is still null - await run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - yield* svc.emitApprovalRequest(sessionId, 'apr-2', 'write_file', { path: 'f.txt' }); - }) - ); - - // Second request overwrites the buffer (only one slot) - expect(bufferedApproval.id).toBe('apr-2'); - - // Consume apr-2 - bufferedApproval = null; - - // apr-1 is lost (single buffer) — this is acceptable because - // in practice, the agent loop blocks on each approval sequentially - } finally { - await run( - Effect.gen(function* () { - const svc = yield* ApprovalWaitService; - yield* svc.unregisterEmitter(sessionId); - }) - ); - } - }); -}); diff --git a/packages/codingcode/test/client/get-session-plan.test.ts b/packages/codingcode/test/client/get-session-plan.test.ts index ff6b8e1a..5495a318 100644 --- a/packages/codingcode/test/client/get-session-plan.test.ts +++ b/packages/codingcode/test/client/get-session-plan.test.ts @@ -1,16 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { ManagedRuntime } from 'effect'; import { createHttpSessionClient } from '../../src/client/http/sessions.js'; -import { createDirectSessionClient } from '../../src/direct/sessions.js'; -import { SessionLayer } from '../../src/session/session.js'; -import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { getProjectBaseDir, encodeProjectPath } from '../../src/core/path.js'; -import { setFakeHome, restoreHome } from '../helpers/temp-home.js'; -describe('getSessionPlan: http + direct both implement', () => { - it('http calls GET /api/sessions/:id/plan?cwd=...', async () => { +describe('getSessionPlan (http)', () => { + it('calls GET /api/sessions/:id/plan?cwd=...', async () => { const calls: string[] = []; const c = createHttpSessionClient({ apiGet: async (p: string) => { @@ -25,24 +17,4 @@ describe('getSessionPlan: http + direct both implement', () => { expect(res.content).toBe('plan'); expect(calls[0]).toBe('/api/sessions/s1/plan?cwd=%2Fc'); }); - - it('direct reads latest .md from project plan directory', async () => { - const home = join(tmpdir(), `plan-test-${Date.now()}`); - const prevHome = setFakeHome(home); - const base = getProjectBaseDir(); - const projectDir = join(base, encodeProjectPath('/my/cwd')); - try { - mkdirSync(projectDir, { recursive: true }); - writeFileSync(join(projectDir, 'first.md'), '# first'); - writeFileSync(join(projectDir, 'second.md'), '# second'); - const rt = ManagedRuntime.make(SessionLayer); - const c = createDirectSessionClient(rt as any); - const res = await c.getSessionPlan({ sessionId: 's1', cwd: '/my/cwd' }); - expect(res.exists).toBe(true); - expect(res.content === '# first' || res.content === '# second').toBe(true); - } finally { - restoreHome(prevHome); - rmSync(home, { recursive: true, force: true }); - } - }); }); diff --git a/packages/tui/package.json b/packages/tui/package.json deleted file mode 100644 index 36ce70c0..00000000 --- a/packages/tui/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@codingcode/tui", - "version": "0.1.0", - "type": "module", - "main": "./src/index.tsx", - "exports": { - ".": "./src/index.tsx" - }, - "dependencies": { - "@codingcode/core": "workspace:*", - "@codingcode/infra": "workspace:*", - "@types/react": "^19.2.14", - "cli-highlight": "^2.1.11", - "ink": "^7.0.3", - "ink-spinner": "^5.0.0", - "ink-testing-library": "^4.0.0", - "ink-text-input": "^6.0.0", - "marked": "^18.0.5", - "react": "^19.2.6" - }, - "devDependencies": { - "@types/marked": "^6.0.0" - } -} diff --git a/packages/tui/src/commands/registry.ts b/packages/tui/src/commands/registry.ts deleted file mode 100644 index c1a01398..00000000 --- a/packages/tui/src/commands/registry.ts +++ /dev/null @@ -1,57 +0,0 @@ -export interface CommandDef { - name: string; - description: string; - usage: string; - title: string; - quick?: boolean; -} - -export const COMMAND_REGISTRY = { - model: { name: 'model', description: '选择模型', usage: '/model', title: '选择模型' }, - sessions: { - name: 'sessions', - description: '恢复历史会话', - usage: '/sessions', - title: '恢复会话', - }, - help: { name: 'help', description: '显示帮助', usage: '/help', title: '帮助' }, - clear: { - name: 'clear', - description: '清空对话', - usage: '/clear', - title: '清空对话', - quick: true, - }, - exit: { name: 'exit', description: '退出', usage: '/exit', title: '退出', quick: true }, - compact: { - name: 'compact', - description: '手动压缩上下文', - usage: '/compact', - title: '压缩上下文', - quick: true, - }, - memory: { - name: 'memory', - description: '查看/切换 Memory', - usage: '/memory [on|off]', - title: 'Memory', - quick: true, - }, - mcp: { name: 'mcp', description: '管理 MCP 服务器', usage: '/mcp', title: 'MCP 服务器' }, - skill: { name: 'skill', description: '查看 Skill', usage: '/skill', title: 'Skill' }, - approve: { - name: 'approve', - description: '切换工具审批模式', - usage: '/approve', - title: '审批模式', - }, -} as const; - -export type CommandName = keyof typeof COMMAND_REGISTRY; - -export function parseCommand(input: string): { name: string; args: string } | null { - if (!input.startsWith('/')) return null; - const parts = input.slice(1).trim().split(/\s+/, 2); - if (!parts[0]) return null; - return { name: parts[0], args: parts[1] ?? '' }; -} diff --git a/packages/tui/src/components/App.tsx b/packages/tui/src/components/App.tsx deleted file mode 100644 index 74c09560..00000000 --- a/packages/tui/src/components/App.tsx +++ /dev/null @@ -1,537 +0,0 @@ -import React, { useState, useCallback, useEffect } from 'react'; -import { Box, Static, useApp, useInput, Text } from 'ink'; -import { useAgentRunner } from '../hooks/useAgentRunner.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { generateId, historyToUIMessages } from '../utils.js'; -import type { PanelState } from '../types.js'; -import type { Frame, TuiClient } from '../index.js'; -import { MessageItem } from './MessageItem.js'; -import { InputBox } from './InputBox.js'; -import { LoadingIndicator } from './LoadingIndicator.js'; -import { InlinePanel } from './InlinePanel.js'; -import { buildWelcomeContent } from './WelcomePanel.js'; -import { - COMMAND_REGISTRY, - parseCommand, - type CommandDef, - type CommandName, -} from '../commands/registry.js'; - -const PERMISSION_MODE_LABELS: Record = { - default: '默认 (逐次确认)', - acceptEdits: '接受编辑 (自动允许文件操作)', - plan: '计划模式 (只读)', - bypass: '完全放行', -}; -interface AppProps { - client: TuiClient; -} - -export function App({ client }: AppProps) { - const { exit } = useApp(); - const { width } = useTerminalSize(); - const [sessionId, setSessionId] = useState('unknown'); - const runner = useCallback( - (input: string) => client.sendMessage(input) as AsyncGenerator, - [client] - ); - const { - staticMessages, - activeMessages, - setStaticMessages, - setActiveMessages, - run, - isRunning, - approval, - setApproval, - } = useAgentRunner(runner); - const [panel, setPanel] = useState({ type: 'none' }); - const [focusedIndex, setFocusedIndex] = useState(null); - const [expandedMap, setExpandedMap] = useState>({}); - const [staticKey, setStaticKey] = useState(0); - const [permissionMode, setPermissionMode] = useState('default'); - - useEffect(() => { - setStaticMessages([ - { - id: generateId(), - timestamp: Date.now(), - role: 'welcome' as const, - content: buildWelcomeContent(), - }, - ]); - setActiveMessages([]); - }, []); // only on mount - - useEffect(() => { - if (activeMessages.length > 0) setFocusedIndex(activeMessages.length - 1); - else setFocusedIndex(null); - }, [activeMessages.length]); - - const handleSend = useCallback( - async (input: string) => { - const trimmed = input.trim(); - if (!trimmed) return; - - const parsed = parseCommand(trimmed); - - if (!parsed) { - await run(trimmed); - const sid = client.getSessionId(); - if (sid !== sessionId) { - setSessionId(sid); - setStaticMessages((prev) => - prev.map((m) => (m.role === 'welcome' ? { ...m, content: buildWelcomeContent() } : m)) - ); - } - return; - } - - const cmd: CommandDef | undefined = COMMAND_REGISTRY[parsed.name as CommandName]; - if (!cmd) { - await run(trimmed); - return; - } - - if (cmd.quick) { - if (parsed.name === 'exit') { - exit(); - return; - } - if (parsed.name === 'clear') { - setStaticMessages([ - { - id: generateId(), - timestamp: Date.now(), - role: 'welcome' as const, - content: buildWelcomeContent(), - }, - ]); - setActiveMessages([]); - setStaticKey((k) => k + 1); - return; - } - if (parsed.name === 'compact') { - try { - await client.compact(); - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system' as const, - content: '[Compact] 上下文已压缩', - }, - ]); - } catch (e: any) { - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system' as const, - content: `[Compact Error] ${e.message || e}`, - }, - ]); - } - return; - } - if (parsed.name === 'memory') { - try { - const arg = parsed.args.trim().toLowerCase(); - if (arg === 'on' || arg === 'off') { - await client.setMemoryEnabled(arg === 'on'); - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system' as const, - content: `[Memory] 已${arg === 'on' ? '开启' : '关闭'}`, - }, - ]); - } else { - const enabled = await client.getMemoryEnabled(); - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system' as const, - content: `[Memory] 当前: ${enabled ? '开启' : '关闭'} 用法: /memory on|off`, - }, - ]); - } - } catch (e: any) { - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system' as const, - content: `[Memory Error] ${e.message || e}`, - }, - ]); - } - return; - } - return; - } - - if (parsed.name === 'model') { - try { - const { models, activeId } = await client.listModels(); - setPanel({ - type: 'model', - items: models.map((m: any) => ({ - label: m.name || m.id, - value: m.id, - description: `${m.provider}/${m.model}`, - })), - activeValue: activeId, - }); - } catch { - /* ignore - server may not be ready */ - } - return; - } - if (parsed.name === 'sessions') { - try { - const sessions = await client.listSessions(); - setPanel({ - type: 'sessions', - items: sessions.map((s: any) => ({ - label: `${s.title || s.sessionId.slice(0, 8)} ${new Date(s.createdAt).toLocaleString()}`, - value: s.sessionId, - })), - }); - } catch { - /* ignore */ - } - return; - } - if (parsed.name === 'help') { - setPanel({ type: 'help' }); - return; - } - if (parsed.name === 'mcp') { - try { - const servers = await client.getMcpStatus({ cwd: '' }); - setPanel({ type: 'mcp', servers }); - } catch (e: any) { - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system' as const, - content: `[MCP Error] ${e.message || e}`, - }, - ]); - } - return; - } - if (parsed.name === 'skill') { - try { - const skills = await client.listSkills(); - setPanel({ type: 'skill', skills }); - } catch (e: any) { - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system' as const, - content: `[Skill Error] ${e.message || e}`, - }, - ]); - } - return; - } - if (parsed.name === 'approve') { - try { - const sid = client.getSessionId(); - const mode = await client.getPermissionMode({ sessionId: sid, cwd: '' }); - setPermissionMode(mode); - setPanel({ type: 'permission', currentMode: mode }); - } catch (e: any) { - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system' as const, - content: `[Approve Error] ${e.message || e}`, - }, - ]); - } - return; - } - }, - [client, run, exit, sessionId] - ); - - // 审批面板:用户选择后发送响应 - const handleApprovalResponse = useCallback( - async (response: string) => { - if (!approval) return; - await client.sendApprovalResponse(approval.id, response); - approval.resolve(response); - }, - [approval, client] - ); - - useInput((input, key) => { - // 审批面板激活时,键盘由 InlinePanel 处理 - if (approval) return; - - if (panel.type === 'help') { - if (key.escape) setPanel({ type: 'none' }); - return; - } - if (panel.type !== 'none') return; - - if (key.upArrow) { - if (activeMessages.length === 0) return; - setFocusedIndex((prev) => - prev === null || prev <= 0 ? activeMessages.length - 1 : prev - 1 - ); - return; - } - if (key.downArrow) { - if (activeMessages.length === 0) return; - setFocusedIndex((prev) => - prev === null || prev >= activeMessages.length - 1 ? 0 : prev + 1 - ); - return; - } - if (key.ctrl && input === 'o' && focusedIndex !== null) { - const msg = activeMessages[focusedIndex]; - if (msg) setExpandedMap((prev) => ({ ...prev, [msg.id]: !prev[msg.id] })); - } - }); - - const modelW = Math.min(60, width - 4); - const sessionW = Math.min(70, width - 4); - const helpW = Math.min(50, width - 4); - const helpInnerW = Math.max(1, helpW - 2); - - return ( - - - {(msg) => } - - - - {activeMessages.map((msg, index) => ( - - ))} - {isRunning && !approval && } - - - {panel.type === 'model' && ( - { - await client.switchModel(value); - setPanel({ type: 'none' }); - }} - onCancel={() => setPanel({ type: 'none' })} - width={modelW} - /> - )} - {panel.type === 'sessions' && ( - { - const history = await client.resumeSession(value); - const uiMsgs = historyToUIMessages(history as any); - setStaticMessages(uiMsgs); - setSessionId(value); - setPanel({ type: 'none' }); - setStaticKey((k) => k + 1); - }} - onCancel={() => setPanel({ type: 'none' })} - width={sessionW} - /> - )} - {approval && ( - - - - 🔒 审批请求 —{' '} - - {approval.tool} - - - {Object.entries(approval.args).map(([k, v]) => ( - - {k}: - {String(v).slice(0, 150)} - - ))} - - - {'─'.repeat(Math.min(40, width - 6))} - - { - await client.sendApprovalResponse(approval.id, value); - approval.resolve(value); - }} - onCancel={() => handleApprovalResponse('deny')} - width={Math.min(50, width - 4)} - /> - - )} - {panel.type === 'mcp' && ( - ({ - label: `${!s.enabled ? '○' : '●'} ${s.name} (${!s.enabled ? '已禁用' : `已连接, ${s.toolCount} 个工具`})`, - value: s.name, - })) - } - onSelect={async (value) => { - if (!value) return; - const server = panel.servers.find((s) => s.name === value); - if (!server) return; - try { - await client.setMcpEnabled({ name: value, enabled: !server.enabled, cwd: '' }); - const updated = await client.getMcpStatus({ cwd: '' }); - setPanel({ type: 'mcp', servers: updated }); - } catch { - setPanel({ type: 'none' }); - } - }} - onCancel={() => setPanel({ type: 'none' })} - width={Math.min(60, width - 4)} - /> - )} - {panel.type === 'skill' && ( - ({ - label: `${s.name} ${s.description}`, - value: s.name, - description: s.skillPath, - })) - } - onSelect={() => setPanel({ type: 'none' })} - onCancel={() => setPanel({ type: 'none' })} - width={Math.min(80, width - 4)} - /> - )} - {panel.type === 'permission' && ( - { - if (!value) return; - try { - const sid = client.getSessionId(); - await client.setPermissionMode({ sessionId: sid, cwd: '', mode: value as any }); - setPermissionMode(value); - } catch { - /* ignore */ - } - setPanel({ type: 'none' }); - }} - onCancel={() => setPanel({ type: 'none' })} - width={Math.min(60, width - 4)} - /> - )} - {panel.type === 'help' && ( - - - - {COMMAND_REGISTRY.help.title} - - - - {'─'.repeat(helpInnerW)} - - - 命令: - {Object.values(COMMAND_REGISTRY).map((cmd) => ( - - - {cmd.usage.padEnd(12)} - {cmd.description} - - - ))} - - - 快捷键: - - - ↑/↓ 聚焦消息 - Ctrl+O 展开/折叠消息 - - - {'─'.repeat(helpInnerW)} - - - Esc 关闭 - - - )} - - - ○ {PERMISSION_MODE_LABELS[permissionMode] ?? permissionMode} - - - - ); -} diff --git a/packages/tui/src/components/CodeBlock.tsx b/packages/tui/src/components/CodeBlock.tsx deleted file mode 100644 index 4a3e664d..00000000 --- a/packages/tui/src/components/CodeBlock.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { Box, Text } from 'ink'; -import { highlight } from 'cli-highlight'; - -interface Props { - code: string; - language: string; -} - -export function CodeBlock({ code, language }: Props) { - let highlighted: string; - try { - highlighted = highlight(code, { - language, - theme: { - keyword: (s) => `\x1b[56m${s}\x1b[0m`, - built_in: (s) => `\x1b[36m${s}\x1b[0m`, - type: (s) => `\x1b[4m\x1b[36m${s}\x1b[0m`, - literal: (s) => `\x1b[35m${s}\x1b[0m`, - number: (s) => `\x1b[35m${s}\x1b[0m`, - string: (s) => `\x1b[32m${s}\x1b[0m`, - comment: (s) => `\x1b[90m${s}\x1b[0m`, - function: (s) => `\x1b[33m${s}\x1b[0m`, - variable: (s) => `\x1b[37m${s}\x1b[0m`, - attr: (s) => `\x1b[36m${s}\x1b[0m`, - tag: (s) => `\x1b[34m${s}\x1b[0m`, - name: (s) => `\x1b[33m${s}\x1b[0m`, - attribute: (s) => `\x1b[36m${s}\x1b[0m`, - meta: (s) => `\x1b[90m${s}\x1b[0m`, - } as Record string>, - }); - } catch { - highlighted = code; - } - - const lines = highlighted.split('\n'); - - return ( - - - {language} - - {lines.map((line, i) => ( - {line} - ))} - - ); -} diff --git a/packages/tui/src/components/InlinePanel.tsx b/packages/tui/src/components/InlinePanel.tsx deleted file mode 100644 index fda8c531..00000000 --- a/packages/tui/src/components/InlinePanel.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React, { useState, useMemo, useEffect } from 'react'; -import { Box, Text, useInput } from 'ink'; -import type { PanelItem } from '../types.js'; - -interface InlinePanelProps { - title: string; - items: PanelItem[]; - activeValue?: T | null; - onSelect: (value: T) => void; - onCancel: () => void; - width: number; - maxHeight?: number; -} - -function charWidth(char: string): number { - const codePoint = char.codePointAt(0) ?? 0; - if ( - (codePoint >= 0x1100 && codePoint <= 0x11ff) || - (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || - (codePoint >= 0xac00 && codePoint <= 0xd7af) || - (codePoint >= 0xf900 && codePoint <= 0xfaff) || - (codePoint >= 0xfe10 && codePoint <= 0xfe6f) || - (codePoint >= 0xff00 && codePoint <= 0xff60) || - (codePoint >= 0xffe0 && codePoint <= 0xffe6) || - (codePoint >= 0x1f300 && codePoint <= 0x1faff) - ) { - return 2; - } - return 1; -} - -export function truncateToDisplayWidth(value: string, maxWidth: number): string { - const normalized = value.replace(/\s+/g, ' ').trim(); - if (maxWidth <= 0) return ''; - - let width = 0; - let result = ''; - for (const char of normalized) { - const nextWidth = width + charWidth(char); - if (nextWidth > maxWidth) { - while (result && width + 1 > maxWidth) { - const last = Array.from(result).pop() ?? ''; - result = result.slice(0, -last.length); - width -= charWidth(last); - } - return result + '…'; - } - result += char; - width = nextWidth; - } - - return result; -} - -export function getVisiblePanelRange( - selectedIdx: number, - itemCount: number, - maxHeight: number -): [number, number] { - if (itemCount <= 0 || maxHeight <= 0) return [0, 0]; - const visibleHeight = Math.min(itemCount, maxHeight); - const selected = Math.max(0, Math.min(selectedIdx, itemCount - 1)); - const start = Math.min( - Math.max(0, selected - visibleHeight + 1), - Math.max(0, itemCount - visibleHeight) - ); - return [start, start + visibleHeight]; -} - -export function InlinePanel({ - title, - items, - activeValue, - onSelect, - onCancel, - width, - maxHeight = 10, -}: InlinePanelProps) { - const [searchText, setSearchText] = useState(''); - const [selectedIdx, setSelectedIdx] = useState(() => { - if (activeValue) { - const idx = items.findIndex((i) => i.value === activeValue); - return idx >= 0 ? idx : 0; - } - return 0; - }); - - useEffect(() => { - setSearchText(''); - if (activeValue) { - const idx = items.findIndex((i) => i.value === activeValue); - setSelectedIdx(idx >= 0 ? idx : 0); - } else { - setSelectedIdx(0); - } - }, [items, activeValue]); - - const filtered = useMemo(() => { - if (!searchText) return items; - const q = searchText.toLowerCase(); - return items.filter( - (i) => i.label.toLowerCase().includes(q) || (i.description ?? '').toLowerCase().includes(q) - ); - }, [items, searchText]); - - useEffect(() => { - if (selectedIdx >= filtered.length) { - setSelectedIdx(Math.max(0, filtered.length - 1)); - } - }, [filtered.length, selectedIdx]); - - const clampedSelectedIdx = Math.max(0, Math.min(selectedIdx, filtered.length - 1)); - - useInput((input, key) => { - if (key.escape) { - onCancel(); - return; - } - if (key.return) { - const item = filtered[clampedSelectedIdx]; - if (item) onSelect(item.value); - return; - } - if (key.upArrow || (key.tab && key.shift)) { - setSelectedIdx((prev) => (prev <= 0 ? Math.max(0, filtered.length - 1) : prev - 1)); - return; - } - if (key.downArrow || key.tab) { - setSelectedIdx((prev) => (prev >= filtered.length - 1 ? 0 : prev + 1)); - return; - } - if (key.backspace || key.delete) { - setSearchText((s) => s.slice(0, -1)); - setSelectedIdx(0); - return; - } - if (input && !key.ctrl && !key.meta) { - setSearchText((s) => (s + input).slice(0, 50)); - setSelectedIdx(0); - } - }); - - const panelWidth = Math.max(10, width); - const innerW = Math.max(1, panelWidth - 4); - const lineStr = '─'.repeat(innerW); - const visibleHeight = Math.min(filtered.length, maxHeight); - const [visibleStart, visibleEnd] = getVisiblePanelRange( - clampedSelectedIdx, - filtered.length, - maxHeight - ); - const visibleItems = filtered.slice(visibleStart, visibleEnd); - const itemWidth = Math.max(1, innerW - 1); - - return ( - - - - {title} - - - {' '} - · {filtered.length}/{items.length} - - {searchText !== '' && · 筛选: {searchText}_} - - - - {lineStr} - - - - {filtered.length === 0 ? ( - - 没有匹配的选项 - - ) : ( - visibleItems.map((item, offset) => { - const index = visibleStart + offset; - const isSelected = index === clampedSelectedIdx; - const isActive = item.value === activeValue; - const indicator = isSelected ? '▸' : ' '; - const dot = isActive ? '●' : ' '; - const desc = item.description ? ` · ${item.description}` : ''; - const line = truncateToDisplayWidth( - `${indicator} ${dot} ${item.label}${desc}`, - itemWidth - ); - - return ( - - - {line} - - - ); - }) - )} - - - - {lineStr} - - - - - ↑↓选择 · Enter确认 · Esc取消{searchText !== '' ? ' · 退格清除筛选' : ' · 输入筛选'} - - - - ); -} diff --git a/packages/tui/src/components/InputBox.tsx b/packages/tui/src/components/InputBox.tsx deleted file mode 100644 index 0547d3a1..00000000 --- a/packages/tui/src/components/InputBox.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, { useState } from 'react'; -import { Box, Text } from 'ink'; -import TextInput from 'ink-text-input'; - -interface Props { - onSubmit: (input: string) => void; - disabled?: boolean; - width?: number; -} - -export function InputBox({ onSubmit, disabled, width = 80 }: Props) { - const [input, setInput] = useState(''); - - const handleSubmit = (value: string) => { - if (!value.trim()) return; - onSubmit(value); - setInput(''); - }; - - const borderColor = disabled ? 'gray' : 'blue'; - const w = Math.max(20, width - 4); - - return ( - - {'─'.repeat(w)} - - - {'> '} - - - - {'─'.repeat(w)} - - ); -} diff --git a/packages/tui/src/components/LoadingIndicator.tsx b/packages/tui/src/components/LoadingIndicator.tsx deleted file mode 100644 index 59181cfb..00000000 --- a/packages/tui/src/components/LoadingIndicator.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import { Box, Text } from 'ink'; -import Spinner from 'ink-spinner'; - -export function LoadingIndicator() { - return ( - - - AI 思考中... - - - ); -} diff --git a/packages/tui/src/components/MarkdownText.tsx b/packages/tui/src/components/MarkdownText.tsx deleted file mode 100644 index 00b92401..00000000 --- a/packages/tui/src/components/MarkdownText.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import React from 'react'; -import { Box, Text } from 'ink'; -import { marked, Token, Tokens } from 'marked'; -import { CodeBlock } from './CodeBlock.js'; - -interface MarkdownTextProps { - content: string; - width: number; -} - -marked.setOptions({ gfm: true, breaks: true }); - -function renderInlineTokens(tokens: Token[] | undefined, width: number): React.ReactNode[] { - if (!tokens || tokens.length === 0) return []; - - const nodes: React.ReactNode[] = []; - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]!; - switch (token.type) { - case 'text': { - const t = token as Tokens.Text; - nodes.push({t.text}); - break; - } - case 'strong': { - const t = token as Tokens.Strong; - nodes.push( - - {renderInlineTokens(t.tokens, width)} - - ); - break; - } - case 'em': { - const t = token as Tokens.Em; - nodes.push( - - {renderInlineTokens(t.tokens, width)} - - ); - break; - } - case 'codespan': { - const t = token as Tokens.Codespan; - nodes.push( - - {' '} - {t.text}{' '} - - ); - break; - } - case 'link': { - const t = token as Tokens.Link; - nodes.push( - - - {renderInlineTokens(t.tokens, width)} - - ({t.href}) - - ); - break; - } - case 'br': - nodes.push({'\n'}); - break; - case 'escape': { - const t = token as Tokens.Escape; - nodes.push({t.text}); - break; - } - default: - if ('text' in token && typeof token.text === 'string') { - nodes.push({token.text}); - } else if ('raw' in token && typeof token.raw === 'string') { - nodes.push({token.raw}); - } - break; - } - } - return nodes; -} - -function renderList( - items: Tokens.ListItem[], - width: number, - ordered: boolean, - start: number | '' = 1 -): React.ReactNode[] { - const nodes: React.ReactNode[] = []; - for (let i = 0; i < items.length; i++) { - const item = items[i]!; - const prefix = ordered ? `${(Number(start) || 1) + i}. ` : '• '; - const inlineNodes = renderInlineTokens(item.tokens, width); - - const subListNodes: React.ReactNode[] = []; - if (item.tokens) { - for (const subToken of item.tokens) { - if (subToken.type === 'list') { - const subList = subToken as Tokens.List; - subListNodes.push( - - {renderList(subList.items, width, subList.ordered, subList.start)} - - ); - } - } - } - - nodes.push( - - - {prefix} - {inlineNodes} - - {subListNodes} - - ); - } - return nodes; -} - -function renderTable(token: Tokens.Table, width: number): React.ReactNode { - const { header, rows } = token; - const colCount = header.length; - - const colWidths = header.map((h, i) => { - const headerLen = h.text.length; - const maxRowLen = Math.max(...rows.map((r) => r[i]?.text.length ?? 0)); - return Math.min( - Math.max(headerLen, maxRowLen) + 1, - Math.floor((width - colCount - 1) / colCount) - ); - }); - - const renderRow = (cells: Tokens.TableCell[], isHeader: boolean) => { - const parts = cells.map((cell, i) => { - const cw = colWidths[i] ?? 0; - const text = cell.text.padEnd(cw).slice(0, cw); - return isHeader ? {text} : {text}; - }); - const separator = │ ; - return ( - - │ - {parts.flatMap((node, i) => (i === 0 ? [node] : [separator, node]))} - │ - - ); - }; - - const separatorLine = '├' + colWidths.map((w) => '─'.repeat(w)).join('┼') + '┤'; - - return ( - - {renderRow(header, true)} - {separatorLine} - {rows.map((row, i) => ( - {renderRow(row, false)} - ))} - - ); -} - -export function MarkdownText({ content, width }: MarkdownTextProps) { - const tokens = marked.lexer(content); - - const nodes: React.ReactNode[] = []; - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]!; - - switch (token.type) { - case 'heading': { - const headingToken = token as Tokens.Heading; - const prefix = '#'.repeat(headingToken.depth) + ' '; - const colors = ['yellow', 'yellow', 'green', 'cyan', 'gray', 'gray'] as const; - nodes.push( - - - {prefix} - {renderInlineTokens(headingToken.tokens, width)} - - - ); - break; - } - case 'paragraph': { - const paraToken = token as Tokens.Paragraph; - nodes.push( - - {renderInlineTokens(paraToken.tokens, width)} - - ); - break; - } - case 'code': { - const codeToken = token as Tokens.Code; - nodes.push(); - break; - } - case 'list': { - const listToken = token as Tokens.List; - nodes.push( - - {renderList(listToken.items, width, listToken.ordered, listToken.start)} - - ); - break; - } - case 'blockquote': { - const bqToken = token as Tokens.Blockquote; - const innerText = bqToken.tokens - .map((t) => { - if ('text' in t) return t.text; - if ('raw' in t) return t.raw; - return ''; - }) - .join('\n'); - nodes.push( - - │ {innerText} - - ); - break; - } - case 'hr': { - nodes.push( - - {'─'.repeat(Math.min(width - 2, 60))} - - ); - break; - } - case 'table': { - const tableToken = token as Tokens.Table; - nodes.push({renderTable(tableToken, width)}); - break; - } - case 'space': - break; - default: - if ('raw' in token && typeof token.raw === 'string') { - nodes.push( - - {token.raw} - - ); - } - break; - } - } - - return {nodes}; -} diff --git a/packages/tui/src/components/MessageItem.tsx b/packages/tui/src/components/MessageItem.tsx deleted file mode 100644 index 126eda74..00000000 --- a/packages/tui/src/components/MessageItem.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import React from 'react'; -import { Box, Text } from 'ink'; -import type { UIMessage } from '../types.js'; -import { MarkdownText } from './MarkdownText.js'; -import { formatTime } from '../utils.js'; - -/** Render a single todo step line with colored status icon and step text */ -function TodoStep({ line }: { line: string }) { - const icon = line[0]; - const step = line.slice(2); - if (icon === '✓') { - return {line}; - } - if (icon === '✗') { - return ( - - {line} - - ); - } - return {line}; -} - -interface Props { - message: UIMessage; - isFocused?: boolean; - width: number; - expanded?: boolean; - interactive?: boolean; -} - -export function MessageItem({ - message, - isFocused = false, - width, - expanded = true, - interactive = true, -}: Props) { - const focusPrefix = interactive && isFocused ? ▸ : null; - const indent = interactive && isFocused ? 3 : 2; - - if (message.role === 'user') { - return ( - - - {focusPrefix} - - {'› '} - {message.content} - - - - - {formatTime(message.timestamp)} - - - - ); - } - - if (message.role === 'tool') { - // Todo messages: render with colored status icons - if (message.toolName?.startsWith('Todo')) { - const lines = message.content.split('\n'); - if (interactive && !expanded) { - return ( - - - {focusPrefix} - - [todo] {message.toolName} · Ctrl+O 展开 - - - - ); - } - return ( - - - {focusPrefix} - - [todo] {message.toolName} - - - - {lines.map((line, i) => ( - - ))} - - - ); - } - // Regular tool messages - if (interactive && !expanded) { - return ( - - - {focusPrefix} - - [tool: {message.toolName}] {message.content.length} chars · Ctrl+O 展开 - - - - ); - } - return ( - - - {focusPrefix} - - [tool: {message.toolName}] - - - - {message.content} - - - ); - } - - if (message.role === 'welcome') { - return ( - - - {message.content} - - - ); - } - - if (message.role === 'system') { - return ( - - - {focusPrefix} - - {message.content} - - - - ); - } - - if (interactive && !expanded) { - const preview = message.content.slice(0, 80).replace(/\n/g, ' '); - return ( - - - {focusPrefix} - - AI - - {message.isStreaming && {'⠋'}} - {message.model && ( - - {' '} - · {message.model} - - )} - - - - {preview} - {message.content.length > 80 ? '...' : ''} · Ctrl+O 展开 - - - - ); - } - - return ( - - - {focusPrefix} - - AI - - {message.isStreaming && {'⠋'}} - {message.model && ( - - {' '} - · {message.model} - - )} - - - - - - ); -} diff --git a/packages/tui/src/components/WelcomePanel.tsx b/packages/tui/src/components/WelcomePanel.tsx deleted file mode 100644 index e7ec01a0..00000000 --- a/packages/tui/src/components/WelcomePanel.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export function buildWelcomeContent(): string { - return [ - '', - ' ▄█████ ▄████▄ ████▄ ██ ███ ██ ▄████ ▄█████ ▄████▄ ████▄ ██████', - ' ██ ██ ██ ██ ██ ██ ██ ▀▄██ ██ ▄▄▄ ██ ██ ██ ██ ██ ██▄▄', - ' ▀█████ ▀████▀ ████▀ ██ ██ ██ ▀███▀ ▀█████ ▀████▀ ████▀ ██▄▄▄▄', - '', - ' Type /help for available commands.', - '', - ].join('\n'); -} diff --git a/packages/tui/src/hooks/useAgentRunner.ts b/packages/tui/src/hooks/useAgentRunner.ts deleted file mode 100644 index a03b6366..00000000 --- a/packages/tui/src/hooks/useAgentRunner.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { useState, useCallback, useRef } from 'react'; -import type { UIMessage } from '../types.js'; -import type { Frame } from '../index.js'; -import { generateId } from '../utils.js'; - -export interface ApprovalPanel { - id: string; - tool: string; - args: Record; - resolve: (response: string) => void; -} - -export function useAgentRunner(runner: (input: string) => AsyncGenerator) { - const [staticMessages, setStaticMessages] = useState([]); - const [activeMessages, setActiveMessages] = useState([]); - const [isRunning, setIsRunning] = useState(false); - const [approval, setApproval] = useState(null); - - const run = useCallback( - async (input: string) => { - setIsRunning(true); - - const userMsg: UIMessage = { - id: generateId(), - timestamp: Date.now(), - role: 'user', - content: input, - }; - setStaticMessages((prev) => [...prev, userMsg]); - - const assistantId = generateId(); - let assistantContent = ''; - const pendingTodods: UIMessage[] = []; - - setActiveMessages([ - { - id: assistantId, - timestamp: Date.now(), - role: 'assistant', - content: '', - isStreaming: true, - }, - ]); - - try { - const stream = runner(input); - - for await (const frame of stream) { - if (frame.family === 'fatal') { - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system', - content: `[Error${frame.fatal.code ? ` ${frame.fatal.code}` : ''}] ${frame.fatal.message}`, - }, - ]); - continue; - } - - if (frame.family === 'transition') { - if (frame.transition.to === 'end' && frame.transition.reason === 'error') { - const { message, code } = frame.transition.error; - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system', - content: `[Error${code ? ` ${code}` : ''}] ${message}`, - }, - ]); - } - continue; - } - - const event = frame.event; - if (event.type === 'text_delta') { - assistantContent += event.text; - setActiveMessages([ - { - id: assistantId, - timestamp: Date.now(), - role: 'assistant', - content: assistantContent, - isStreaming: true, - }, - ]); - } else if (event.type === 'tool_call') { - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'tool', - content: '', - toolName: event.name, - }, - ]); - } else if (event.type === 'approval_request') { - // 收到审批请求 → 显示 InlinePanel,暂停流读取 - await new Promise((resolve) => { - setApproval({ - id: event.id, - tool: event.tool, - args: { ...event.args }, - resolve, - }); - }); - // 用户已选择 → 关闭面板,继续流 - setApproval(null); - } else if (event.type === 'tool_result') { - const outcome = event.outcome; - if (outcome.status === 'denied') { - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system', - content: `⛔ Tool "${event.name}" was denied: ${outcome.reason || 'not allowed'}`, - toolName: event.name, - }, - ]); - } - if (event.todos) { - const items = event.todos as Array<{ step: string; status: string }>; - const pending = items.filter((t) => t.status === 'pending'); - const completed = items.filter((t) => t.status === 'completed'); - const cancelled = items.filter((t) => t.status === 'cancelled'); - const summary = - `${pending.length} 进行中, ${completed.length} 已完成` + - (cancelled.length > 0 ? `, ${cancelled.length} 已取消` : ''); - pendingTodods.push({ - id: generateId(), - timestamp: Date.now(), - role: 'tool', - content: items - .map((t) => { - const icon = - t.status === 'completed' ? '✓' : t.status === 'cancelled' ? '✗' : '○'; - return `${icon} ${t.step}`; - }) - .join('\n'), - toolName: `Todo (${summary})`, - }); - } - } - } - - setActiveMessages([]); - setStaticMessages((prev) => [ - ...prev, - { - id: assistantId, - timestamp: Date.now(), - role: 'assistant', - content: assistantContent, - isStreaming: false, - }, - ...pendingTodods, - ]); - } catch (err: any) { - setActiveMessages([]); - setStaticMessages((prev) => [ - ...prev, - { - id: generateId(), - timestamp: Date.now(), - role: 'system', - content: `[Error] ${err.message || err}`, - }, - ]); - } finally { - setIsRunning(false); - } - }, - [runner] - ); - - const clearMessages = useCallback(() => { - setStaticMessages([]); - setActiveMessages([]); - }, []); - - return { - staticMessages, - activeMessages, - setStaticMessages, - setActiveMessages, - run, - isRunning, - clearMessages, - approval, - setApproval, - }; -} diff --git a/packages/tui/src/hooks/useTerminalSize.ts b/packages/tui/src/hooks/useTerminalSize.ts deleted file mode 100644 index fc092603..00000000 --- a/packages/tui/src/hooks/useTerminalSize.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useStdout } from 'ink'; - -export function useTerminalSize() { - const { stdout } = useStdout(); - const [size, setSize] = useState({ - width: stdout.columns || 80, - height: stdout.rows || 24, - }); - - useEffect(() => { - const handler = () => { - setSize({ width: stdout.columns, height: stdout.rows }); - }; - stdout.on('resize', handler); - return () => { - stdout.off('resize', handler); - }; - }, [stdout]); - - return size; -} diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx deleted file mode 100644 index 60937f5e..00000000 --- a/packages/tui/src/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { render } from 'ink'; -import { App } from './components/App.js'; -import type { Frame } from '@codingcode/core/contracts/frame'; -import { createDirectAgentClient } from '@codingcode/core/direct/agent-runtime'; -import { createDirectSessionClient } from '@codingcode/core/direct/sessions'; -import { createDirectSettingsClient } from '@codingcode/core/direct/settings'; -import { createDirectModelClient } from '@codingcode/core/direct/models'; -import type { LLMClient } from '@codingcode/core/contracts/provider'; -import type { AppRuntime } from '@codingcode/core'; - -export type { Frame }; - -export interface TuiClient { - sendMessage(input: string): AsyncGenerator; - sendApprovalResponse(id: string, response: string): Promise; - getSessionId(): string; - compact(): Promise; - setMemoryEnabled(enabled: boolean): Promise; - getMemoryEnabled(): Promise; - listModels(): Promise<{ models: any[]; activeId: string | null }>; - switchModel(id: string): Promise; - listSessions(): Promise; - getMcpStatus(query: { cwd: string }): Promise; - setMcpEnabled(body: { name: string; enabled: boolean; cwd: string }): Promise; - listSkills(): Promise>; - getPermissionMode(input: { - sessionId: string; - cwd: string; - }): Promise; - setPermissionMode(input: { - sessionId: string; - cwd: string; - mode: import('@codingcode/core/contracts/permission').PermissionMode; - }): Promise; - resumeSession(sid: string): Promise; -} - -export function createTuiClientFromFacades(llm: LLMClient, rt: AppRuntime): TuiClient { - const agent = createDirectAgentClient(llm, rt); - const sessions = createDirectSessionClient(rt); - const settings = createDirectSettingsClient(rt); - const models = createDirectModelClient(rt); - - let currentSessionId = ''; - - return { - async *sendMessage(input: string): AsyncGenerator { - const stream = agent.sendMessage(input, { sessionId: currentSessionId, cwd: '' }); - for await (const frame of stream) { - // 会话 id 只来自信封,是流的唯一权威来源。 - currentSessionId = frame.sessionId; - yield frame; - } - }, - sendApprovalResponse: (id, response) => - agent.sendApprovalResponse({ sessionId: currentSessionId, approvalId: id, response }), - getSessionId: () => currentSessionId, - compact: () => agent.compact({ sessionId: currentSessionId, cwd: '' }), - setMemoryEnabled: (enabled) => settings.setMemoryEnabled(enabled), - getMemoryEnabled: () => settings.getMemoryEnabled(), - listModels: () => models.listModels(), - switchModel: (id) => models.switchModel({ id }), - listSessions: () => sessions.listSessions({ cwd: '' }), - getMcpStatus: (query) => settings.getMcpStatus(query), - setMcpEnabled: (body) => settings.setMcpEnabled(body), - listSkills: () => settings.listSkills({ cwd: '' }), - getPermissionMode: (input) => settings.getGlobalPermissionMode(input), - setPermissionMode: (input) => settings.setGlobalPermissionMode(input), - resumeSession: (sid) => sessions.resumeSession({ sessionId: sid, cwd: '' }), - }; -} - -interface TuiOptions { - client: TuiClient; -} - -export async function runTui(options: TuiOptions) { - render(); -} diff --git a/packages/tui/src/types.ts b/packages/tui/src/types.ts deleted file mode 100644 index bb4e8729..00000000 --- a/packages/tui/src/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -export interface UIMessage { - id: string; - timestamp: number; - role: 'user' | 'assistant' | 'tool' | 'system' | 'welcome'; - content: string; - isStreaming?: boolean; - model?: string; - toolName?: string; -} - -export interface SessionIndex { - sessionId: string; - projectSlug: string; - cwd: string; - model: string; - createdAt: string; - updatedAt: string; - messageCount: number; -} - -export interface PanelItem { - label: string; - value: T; - description?: string; -} - -export interface McpServerStatus { - name: string; - enabled: boolean; - toolCount: number; - transport: 'stdio' | 'http'; -} - -export interface SkillStatus { - name: string; - description: string; - skillPath: string; -} - -export type PanelState = - | { type: 'none' } - | { type: 'model'; items: PanelItem[]; activeValue: string | null } - | { type: 'sessions'; items: PanelItem[] } - | { type: 'approval'; id: string; tool: string; args: Record } - | { type: 'help' } - | { type: 'mcp'; servers: McpServerStatus[] } - | { type: 'skill'; skills: SkillStatus[] } - | { type: 'permission'; currentMode: string }; diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts deleted file mode 100644 index b8774433..00000000 --- a/packages/tui/src/utils.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { randomBytes } from 'crypto'; -import type { UIMessage } from './types.js'; - -type SessionEvent = { - type: string; - turnId?: number; - content?: string; - output?: string; - model?: string; - toolName?: string; - toolCallId?: string; -}; - -export function generateId(): string { - return randomBytes(8).toString('hex'); -} - -export function formatTime(ts: number): string { - const d = new Date(ts); - return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`; -} - -function createTurnScopedIdGenerator() { - const counters = new Map(); - return (prefix: string, turnId: number): string => { - const key = `${prefix}:${turnId}`; - const next = (counters.get(key) ?? 0) + 1; - counters.set(key, next); - return `${prefix}-${turnId}-${next}`; - }; -} - -export function historyToUIMessages(history: SessionEvent[]): UIMessage[] { - const messages: UIMessage[] = []; - const nextId = createTurnScopedIdGenerator(); - - for (const event of history) { - switch (event.type) { - case 'user': { - if (event.turnId === undefined) break; - messages.push({ - id: nextId('user', event.turnId), - timestamp: Date.now(), - role: 'user', - content: event.content ?? '', - }); - break; - } - case 'assistant': { - if (event.turnId === undefined) break; - messages.push({ - id: nextId('assistant', event.turnId), - timestamp: Date.now(), - role: 'assistant', - content: event.content ?? '', - model: event.model, - }); - break; - } - case 'tool_result': { - if (event.toolCallId === undefined) break; - messages.push({ - id: `result-${event.toolCallId}`, - timestamp: Date.now(), - role: 'tool', - content: event.output ?? '', - toolName: event.toolName, - }); - break; - } - } - } - return messages; -} diff --git a/packages/tui/test/components/App.test.tsx b/packages/tui/test/components/App.test.tsx deleted file mode 100644 index 6701dcd2..00000000 --- a/packages/tui/test/components/App.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { render } from 'ink-testing-library'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('../../src/hooks/useAgentRunner.js', () => ({ - useAgentRunner: () => ({ - staticMessages: [], - activeMessages: [], - setStaticMessages: vi.fn(), - setActiveMessages: vi.fn(), - run: vi.fn(), - isRunning: false, - approval: null, - setApproval: vi.fn(), - }), -})); - -vi.mock('../../src/hooks/useTerminalSize.js', () => ({ - useTerminalSize: () => ({ width: 80, height: 24 }), -})); - -vi.mock('../../src/components/MessageItem.js', () => ({ - MessageItem: () => null, -})); -vi.mock('../../src/components/InputBox.js', () => ({ - InputBox: () => null, -})); -vi.mock('../../src/components/LoadingIndicator.js', () => ({ - LoadingIndicator: () => null, -})); -vi.mock('../../src/components/InlinePanel.js', () => ({ - InlinePanel: () => null, -})); - -import { App } from '../../src/components/App.js'; - -const client = { - sendMessage: vi.fn(), - sendApprovalResponse: vi.fn(), - getSessionId: vi.fn(() => 'test-session'), - compact: vi.fn(), - setMemoryEnabled: vi.fn(), - getMemoryEnabled: vi.fn(), - listModels: vi.fn(), - switchModel: vi.fn(), - listSessions: vi.fn(), - getMcpStatus: vi.fn(), - setMcpEnabled: vi.fn(), - listSkills: vi.fn(), - getPermissionMode: vi.fn(), - setPermissionMode: vi.fn(), - resumeSession: vi.fn(), -}; - -describe('App', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('renders JSX without requiring a missing React runtime binding', () => { - const instance = render(); - - expect(instance.lastFrame()).toBeDefined(); - instance.unmount(); - }); -}); diff --git a/packages/tui/test/components/InlinePanel.test.ts b/packages/tui/test/components/InlinePanel.test.ts deleted file mode 100644 index 9f53a752..00000000 --- a/packages/tui/test/components/InlinePanel.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getVisiblePanelRange, truncateToDisplayWidth } from '../../src/components/InlinePanel.js'; - -describe('InlinePanel', () => { - it('truncates long session labels to one display line', () => { - const label = '修复一个非常长的会话标题,避免会话选择列表发生换行重叠 2026/5/21 02:30:00'; - - expect(truncateToDisplayWidth(label, 20)).toBe('修复一个非常长的会…'); - }); - - it('normalizes whitespace before rendering a row', () => { - expect(truncateToDisplayWidth('session\nwith\tspaces', 30)).toBe('session with spaces'); - }); - - it('keeps the selected session inside the visible window', () => { - expect(getVisiblePanelRange(0, 12, 5)).toEqual([0, 5]); - expect(getVisiblePanelRange(6, 12, 5)).toEqual([2, 7]); - expect(getVisiblePanelRange(11, 12, 5)).toEqual([7, 12]); - }); -}); diff --git a/packages/tui/test/components/MarkdownText.test.ts b/packages/tui/test/components/MarkdownText.test.ts deleted file mode 100644 index 53e14db2..00000000 --- a/packages/tui/test/components/MarkdownText.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { createElement } from 'react'; -import { render } from 'ink-testing-library'; -import { MarkdownText } from '../../src/components/MarkdownText.js'; - -vi.mock('cli-highlight', () => ({ - highlight: (code: string) => code, -})); - -const mk = (content: string, width: number = 80) => createElement(MarkdownText, { content, width }); - -describe('MarkdownText', () => { - it('renders plain text', () => { - const { lastFrame } = render(mk('hello world')); - expect(lastFrame()).toContain('hello world'); - }); - - it('renders heading with prefix', () => { - const { lastFrame } = render(mk('# Title')); - expect(lastFrame()).toContain('# Title'); - }); - - it('renders inline code', () => { - const { lastFrame } = render(mk('use `npm install`')); - expect(lastFrame()).toContain('npm install'); - }); - - it('renders code block', () => { - const { lastFrame } = render(mk("```js\nconsole.log('hi')\n```")); - expect(lastFrame()).toContain("console.log('hi')"); - expect(lastFrame()).toContain('js'); - }); - - it('renders unordered list', () => { - const { lastFrame } = render(mk('- item1\n- item2')); - expect(lastFrame()).toContain('item1'); - expect(lastFrame()).toContain('item2'); - }); - - it('renders blockquote', () => { - const { lastFrame } = render(mk('> quoted')); - expect(lastFrame()).toContain('quoted'); - }); - - it('renders hr separator', () => { - const { lastFrame } = render(mk('above\n\n---\n\nbelow')); - expect(lastFrame()).toContain('─'); - }); - - it('renders bold text', () => { - const { lastFrame } = render(mk('**bold**')); - expect(lastFrame()).toContain('bold'); - }); - - it('renders link with URL', () => { - const { lastFrame } = render(mk('[click](https://example.com)')); - expect(lastFrame()).toContain('click'); - expect(lastFrame()).toContain('https://example.com'); - }); - - it('handles empty content', () => { - const { lastFrame } = render(mk('')); - expect(lastFrame()).toBe(''); - }); -}); diff --git a/packages/tui/test/components/WelcomePanel.test.ts b/packages/tui/test/components/WelcomePanel.test.ts deleted file mode 100644 index 253233bc..00000000 --- a/packages/tui/test/components/WelcomePanel.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { buildWelcomeContent } from '../../src/components/WelcomePanel.js'; - -describe('buildWelcomeContent', () => { - it('should contain CODING CODE ASCII art banner', () => { - const content = buildWelcomeContent(); - expect(content).toContain('▄█████'); - expect(content).toContain('▀█████'); - expect(content).toContain('Type /help for available commands.'); - }); - - it('should not contain Model, Role, or Session lines', () => { - const content = buildWelcomeContent(); - expect(content).not.toContain('Model:'); - expect(content).not.toContain('Role:'); - expect(content).not.toContain('Session:'); - }); - - it('should return a non-empty string', () => { - const content = buildWelcomeContent(); - expect(content.length).toBeGreaterThan(0); - }); -}); diff --git a/packages/tui/test/utils.test.ts b/packages/tui/test/utils.test.ts deleted file mode 100644 index 43d976b4..00000000 --- a/packages/tui/test/utils.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { historyToUIMessages } from '../src/utils.js'; - -describe('historyToUIMessages', () => { - it('should return empty array for empty history', () => { - expect(historyToUIMessages([])).toEqual([]); - }); - - it('should convert user events to UIMessage', () => { - const history = [{ type: 'user', turnId: 1, content: 'hello' }]; - const result = historyToUIMessages(history); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - id: 'user-1-1', - role: 'user', - content: 'hello', - }); - expect(typeof result[0].timestamp).toBe('number'); - }); - - it('should convert assistant events to UIMessage', () => { - const history = [{ type: 'assistant', turnId: 1, content: 'hi there' }]; - const result = historyToUIMessages(history); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - id: 'assistant-1-1', - role: 'assistant', - content: 'hi there', - }); - expect(typeof result[0].timestamp).toBe('number'); - }); - - it('should convert tool_result events to UIMessage with toolName', () => { - const history = [ - { - type: 'tool_result', - toolCallId: 'tc1', - output: 'result', - toolName: 'read', - }, - ]; - const result = historyToUIMessages(history); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - id: 'result-tc1', - role: 'tool', - content: 'result', - toolName: 'read', - }); - expect(typeof result[0].timestamp).toBe('number'); - }); - - it('should skip session_meta, role_switch, and compact_boundary events', () => { - const history = [ - { - type: 'session_meta', - sessionId: 's1', - projectSlug: 'test', - cwd: '/', - model: 'm', - role: 'coder', - createdAt: '', - version: '1', - }, - { type: 'user', turnId: 1, content: 'hello' }, - { type: 'role_switch', fromRole: 'a', toRole: 'b' }, - { type: 'assistant', turnId: 2, content: 'hi' }, - { - type: 'compact_boundary', - summary: '...', - replacedRange: [0, 1], - messageCount: 1, - }, - ]; - const result = historyToUIMessages(history); - expect(result).toHaveLength(2); - expect(result[0].role).toBe('user'); - expect(result[1].role).toBe('assistant'); - }); - - it('should handle conversation with interleaved tool calls', () => { - const history = [ - { type: 'user', turnId: 1, content: 'read file' }, - { - type: 'assistant', - turnId: 2, - content: 'let me read that', - model: 'test-model', - toolCalls: [{ id: 'tc1', name: 'read', arguments: '{}' }], - }, - { - type: 'tool_result', - content: undefined, - output: 'file contents here', - toolName: 'read', - toolCallId: 'tc1', - }, - { - type: 'assistant', - turnId: 3, - content: 'the file contains...', - model: 'test-model', - toolCalls: [], - }, - ]; - const result = historyToUIMessages(history); - expect(result).toHaveLength(4); - expect(result[0].role).toBe('user'); - expect(result[0].id).toBe('user-1-1'); - expect(result[1].role).toBe('assistant'); - expect(result[1].id).toBe('assistant-2-1'); - expect(result[1].model).toBe('test-model'); - expect(result[2].role).toBe('tool'); - expect(result[2].id).toBe('result-tc1'); - expect(result[2].toolName).toBe('read'); - expect(result[2].content).toBe('file contents here'); - expect(result[3].role).toBe('assistant'); - expect(result[3].id).toBe('assistant-3-1'); - }); - - it('should preserve message order from history', () => { - const history = [ - { type: 'user', turnId: 1, content: 'msg1' }, - { type: 'user', turnId: 2, content: 'msg2' }, - { type: 'user', turnId: 3, content: 'msg3' }, - ]; - const result = historyToUIMessages(history); - expect(result.map((m) => m.id)).toEqual(['user-1-1', 'user-2-1', 'user-3-1']); - }); - - it('should scope per-turn ids independently for same turn', () => { - const history = [ - { type: 'user', turnId: 1, content: 'msg1' }, - { type: 'user', turnId: 1, content: 'msg2' }, - { type: 'assistant', turnId: 1, content: 'msg3' }, - { type: 'assistant', turnId: 1, content: 'msg4' }, - ]; - const result = historyToUIMessages(history); - expect(result.map((m) => m.id)).toEqual([ - 'user-1-1', - 'user-1-2', - 'assistant-1-1', - 'assistant-1-2', - ]); - }); -}); diff --git a/packages/tui/tsconfig.json b/packages/tui/tsconfig.json deleted file mode 100644 index eb92f547..00000000 --- a/packages/tui/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "outDir": "./dist", - "rootDir": "./src", - "module": "ESNext", - "moduleResolution": "bundler" - }, - "include": ["src/**/*.ts", "src/**/*.tsx"], - "references": [{ "path": "../codingcode" }] -} diff --git a/tsconfig.json b/tsconfig.json index f457e745..dda7d88a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,6 @@ { "path": "./packages/infra" }, { "path": "./packages/codingcode" }, { "path": "./packages/codingcode/test" }, - { "path": "./packages/desktop" }, - { "path": "./packages/tui" } + { "path": "./packages/desktop" } ] }