mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: ACP 协议版本 remote control (#293)
* fix: 添加 usage 字段缺失时的防御性防护 第三方 API(如智谱 GLM)在某些流式响应中不返回 usage 字段, 导致 usage.input_tokens 访问 undefined 崩溃并连锁影响后续所有请求。 - claude.ts: content_block_stop 创建消息时 fallback 到 EMPTY_USAGE - LocalAgentTask.tsx: usage 为 undefined 时提前返回 - tokens.ts: getTokenCountFromUsage 加 null guard 和 ?? 0 - cost-tracker.ts: input_tokens/output_tokens 加 ?? 0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: ACP Plan 展示 — 支持 session/update plan 类型的可视化 补全 PlanUpdate 类型定义(PlanEntry/Priority/Status),新建 PlanView 组件 渲染进度条、状态图标和优先级标签,在 ChatInterface 中处理 plan 更新逻辑。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: 穷鬼模式下跳过 verification agent 以节省 token Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: 补充 RCS 后端 + 前端测试覆盖 (+116 tests) 后端新增 3 个测试文件 (70 tests): - automationState: normalize/snapshot/equals 纯函数 - client-payload: toClientPayload 协议转换 - transport-normalize: normalizePayload + extractContent 前端新增 2 个测试文件 (46 tests): - utils: formatTime/statusClass/truncate/extractEventText 等 - api-client: getUuid/setUuid/api GET/POST 错误处理 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: RCS ACP 页面添加权限模式选择器 + 权限响应修复 - 新增权限模式选择器 UI(6种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断) - 权限模式通过 ACP _meta 从 web → acp-link → agent 全链路传递 - 修复 PermissionPanel 点击"允许"发送 cancelled 而非 selected 的 bug - 权限模式和模型选择持久化到 localStorage - acp-link 直接连接路径同步支持 permissionMode 透传 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: RCS Web UI 重构 + QR 修复 + ACP 扫描自动跳转 - RCS Web UI 组件全面重构: Dialog 迁移 Radix UI, lazy loading, 主题系统改进, 组件样式优化 - IdentityPanel QR 码显示修复: requestAnimationFrame 延迟绘制 解决 Radix Dialog Portal 挂载时序问题 - ACP QR 扫描自动跳转: IdentityPanel 扫描 ACP 格式 { url, token } 后存储 sessionStorage 并跳转 /code/?acp=1 - 新增 ACPDirectView 组件: ACP 直连视图, 用 ACPClient 连接并 渲染 ACPMain 聊天界面 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: ACP 权限管道改进 — 模式同步 + bypass 检测 + 统一权限流水线 - agent.ts: applySessionMode 同步 appState.toolPermissionContext.mode - agent.ts: bypassPermissions 可用性检测 (非 root 或 sandbox 环境) - permissions.ts: createAcpCanUseTool 接入 hasPermissionsToUseTool 统一权限流水线, 替代原来分散的处理逻辑 - permissions.ts: 支持 onModeChange 回调, 模式变更时实时同步 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: acp-link 支持 permissionMode 默认值传递给 agent 客户端 (Zed/VS Code 等) 的 new_session 不一定携带 permissionMode, 导致 agent 收到 _meta: undefined, permission 回退到 default。 修复: handleNewSession 使用 fallback 链: 客户端传值 > config.permissionMode > ACP_PERMISSION_MODE 环境变量 使用: ACP_PERMISSION_MODE=auto acp-link claude Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: 更新文档及说明 * fix: 修复类型错误 * chore: 提交脚本 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
78
.impeccable.md
Normal file
78
.impeccable.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Impeccable Design Context
|
||||
|
||||
## Users
|
||||
|
||||
**Primary**: Technical teams and enterprises using AI-assisted coding in production workflows.
|
||||
- DevOps engineers managing remote agents via RCS dashboard
|
||||
- Development teams collaborating through shared sessions
|
||||
- Individual developers using terminal CLI daily
|
||||
|
||||
**Context**: Used during focused work sessions — debugging, code review, agent orchestration. Users are in "get things done" mode, not browsing. They value efficiency but also appreciate warmth and personality.
|
||||
|
||||
**Job to be done**: Make advanced AI coding tools accessible and controllable, especially features that normally require enterprise accounts or Anthropic OAuth.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
**3 words**: Warm, Considered, Human
|
||||
|
||||
**Voice**: Like a knowledgeable colleague who's genuinely enthusiastic about the craft — not a corporate product manager. Community-first, open, slightly playful. Chinese developer community culture (贴吧/discord 温暖氛围).
|
||||
|
||||
**Emotional goals**: Confidence (this tool is solid), Warmth (this community is welcoming), Delight (small moments of personality make the difference).
|
||||
|
||||
**References**:
|
||||
- **Anthropic's own design language** — their clean, considered aesthetic with warm undertones. The terra cotta/burnt orange as a human accent. Lots of breathing room. Typography-forward.
|
||||
- **NOT**: Generic AI product (no ChatGPT blue, no gradient text, no "AI slop"). NOT corporate SaaS (no Salesforce-blue dashboards, no enterprise sterility).
|
||||
|
||||
**Anti-references**: Corporate enterprise dashboards, generic AI product pages, anything that looks like it was "designed by committee."
|
||||
|
||||
## Aesthetic Direction
|
||||
|
||||
**Theme**: Light + Dark dual mode (user/system preference switch)
|
||||
|
||||
**Tone**: Anthropomorphic warmth meets terminal precision. The brand orange (Claude's terra cotta) is the thread that ties everything together — it's the human element in a technical world.
|
||||
|
||||
**Typography**: Clean, considered, with good hierarchy. Terminal-native for CLI; modern web fonts for Web UI (RCS dashboard, docs). Favor readability and personality.
|
||||
|
||||
**Color**:
|
||||
- Primary: Claude orange family (`#D77757` / terra cotta)
|
||||
- Accent: Warm neutrals tinted toward orange
|
||||
- Semantic: Success/Error/Warning following Anthropic's established palette
|
||||
- Dark mode: Warm dark surfaces (not cold blue-black)
|
||||
|
||||
**Differentiation**: The CCB brand sits at the intersection of "serious tool" and "community project." It should feel like Anthropic's design principles applied to an open-source context — less corporate polish, more human craft. The mascot "Clawd" and the playful "踩踩背" naming hint at personality that the design should honor.
|
||||
|
||||
**Scope**: All Web UI — RCS control panel, documentation site, landing pages.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Considered over clever** — Every design choice should feel intentional, not trendy. If it doesn't serve the user, it doesn't ship.
|
||||
2. **Warmth through subtlety** — Orange tints on neutrals, breathing room in layouts, personality in copy. Not giant emoji or aggressive color.
|
||||
3. **Density with clarity** — Technical users need information density, but not chaos. Every pixel earns its place.
|
||||
4. **Community voice** — The design should feel like it was made by people who use it, not by a distant design team. Slightly rough edges are fine if they're honest.
|
||||
5. **Anthropic's shadow** — When in doubt, follow Anthropic's design instincts — the clean layouts, the generous spacing, the warm color temperature. Then add the community touch.
|
||||
|
||||
## Existing Design Assets
|
||||
|
||||
### Brand Colors (from theme system)
|
||||
- Claude Orange: `rgb(215,119,87)` / `#D77757`
|
||||
- Claude Blue: `rgb(87,105,247)` / `#5769F7`
|
||||
- Permission Blue: `rgb(87,105,247)`
|
||||
- Auto Accept Violet: `rgb(135,0,255)`
|
||||
- Plan Mode Teal: `rgb(0,102,102)`
|
||||
- Success: `rgb(78,186,101)`
|
||||
- Error: `rgb(255,107,128)`
|
||||
- Warning: `rgb(255,193,7)`
|
||||
|
||||
### Logo
|
||||
- CCB text + orange play button icon
|
||||
- Dark/Light SVG variants in `docs/logo/`
|
||||
- Favicon: Orange circle `#D97706` with white play triangle
|
||||
|
||||
### Mascot
|
||||
- "Clawd" — terminal-art character with multiple poses
|
||||
- Theme-aware coloring
|
||||
|
||||
### Theme System
|
||||
- 7 variants: dark, light, dark-ansi, light-ansi, dark-daltonized, light-daltonized, auto
|
||||
- 89+ semantic color tokens
|
||||
- Full documentation in `packages/@ant/ink/docs/04-theme-system.md`
|
||||
48
CLAUDE.md
48
CLAUDE.md
@@ -43,7 +43,7 @@ bun run build
|
||||
bun run build:vite
|
||||
|
||||
# Test
|
||||
bun test # run all tests (3066 tests / 205 files / 0 fail)
|
||||
bun test # run all tests (3175 tests / 207 files / 0 fail)
|
||||
bun test src/utils/__tests__/hash.test.ts # run single file
|
||||
bun test --coverage # with coverage report
|
||||
|
||||
@@ -157,11 +157,12 @@ bun run docs:dev
|
||||
| `packages/@ant/model-provider/` | Model provider 抽象层 |
|
||||
| `packages/builtin-tools/` | 内置工具集(60 个 tool 实现,通过 `@claude-code-best/builtin-tools` 导出) |
|
||||
| `packages/agent-tools/` | Agent 工具集 |
|
||||
| `packages/acp-link/` | ACP 代理服务器(WebSocket → ACP agent 桥接) |
|
||||
| `packages/cc-knowledge/` | Claude Code 知识库(非 workspace 包) |
|
||||
| `packages/langfuse-dashboard/` | Langfuse 可观测性面板(非 workspace 包) |
|
||||
| `packages/mcp-client/` | MCP 客户端库 |
|
||||
| `packages/mcp-server/` | MCP 服务端库(非 workspace 包) |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI) |
|
||||
| `packages/remote-control-server/` | 自托管 Remote Control Server(Docker 部署,含 Web UI)— Web UI 已重构为 React + Vite + Radix UI,支持 ACP agent 接入 |
|
||||
| `packages/swarm/` | Swarm 解耦模块(非 workspace 包) |
|
||||
| `packages/shell/` | Shell 抽象(非 workspace 包) |
|
||||
| `packages/audio-capture-napi/` | 原生音频捕获(已恢复) |
|
||||
@@ -173,10 +174,17 @@ bun run docs:dev
|
||||
### Bridge / Remote Control
|
||||
|
||||
- **`src/bridge/`** (~38 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板。通过 `bun run rcs` 启动。
|
||||
- **`packages/remote-control-server/`** — 自托管 RCS,支持 Docker 部署,含 Web UI 控制面板(React 19 + Vite + Radix UI)。支持 ACP agent 通过 acp-link 接入(ACP WebSocket handler、relay handler、SSE event stream)。通过 `bun run rcs` 启动。
|
||||
- CLI 快速路径: `claude remote-control` / `claude rc` / `claude bridge`。
|
||||
- 详见 `docs/features/remote-control-self-hosting.md`。
|
||||
|
||||
### ACP Protocol (Agent Client Protocol)
|
||||
|
||||
- **`src/services/acp/`** — ACP agent 实现,包含 `agent.ts`(AcpAgent 类)、`bridge.ts`(Claude Code ↔ ACP 桥接)、`permissions.ts`(权限处理)、`entry.ts`(入口)。
|
||||
- **`packages/acp-link/`** — ACP 代理服务器,将 WebSocket 客户端桥接到 ACP agent。提供 `acp-link` CLI 命令,支持自定义端口/HTTPS/认证/会话管理、RCS 集成(REST 注册 + WS identify 两步流程)、权限模式透传(fallback: 客户端传值 > config > `ACP_PERMISSION_MODE` 环境变量)。
|
||||
- ACP 权限管道改进:`createAcpCanUseTool` 统一权限流水线,`applySessionMode` 模式同步,`bypassPermissions` 可用性检测(非 root/sandbox 环境)。
|
||||
- ACP Plan 可视化已支持 `session/update plan` 类型的消息展示(PlanView 组件,含进度条/状态图标/优先级标签)。
|
||||
|
||||
### Daemon Mode
|
||||
|
||||
- **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。
|
||||
@@ -209,6 +217,12 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
|
||||
支持 OpenAI、Gemini、Grok 三种第三方 API,通过 `/login` 命令配置,均采用流适配器模式转为 Anthropic 内部格式。详见各兼容层的 docs 文档。
|
||||
|
||||
### 穷鬼模式(Budget Mode)
|
||||
|
||||
- 通过 `/poor` 命令切换,持久化到 `settings.json`。
|
||||
- 启用后跳过 `extract_memories`、`prompt_suggestion` 和 `verification_agent`,显著减少 token 消耗。
|
||||
- 实现在 `src/commands/poor/poorMode.ts`。
|
||||
|
||||
### Stubbed/Deleted Modules
|
||||
|
||||
| Module | Status |
|
||||
@@ -233,7 +247,7 @@ Feature flags control which functionality is enabled at runtime. 代码中统一
|
||||
## Testing
|
||||
|
||||
- **框架**: `bun:test`(内置断言 + mock)
|
||||
- **当前状态**: 3066 tests / 205 files / 0 fail
|
||||
- **当前状态**: 3175 tests / 207 files / 0 fail
|
||||
- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `<module>.test.ts`
|
||||
- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain)
|
||||
- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/)
|
||||
@@ -278,3 +292,29 @@ bun run typecheck # equivalent to bun run typecheck
|
||||
- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。
|
||||
- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。
|
||||
- **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。
|
||||
|
||||
## Design Context
|
||||
|
||||
Impeccable 设计上下文保存在 `.impeccable.md` 中。设计 Web UI(RCS 控制面板、文档站、着陆页)时必须参考该文件。
|
||||
|
||||
### 核心设计原则
|
||||
|
||||
1. **Considered over clever** — 每个设计选择都应感觉有意为之,而非追逐潮流
|
||||
2. **Warmth through subtlety** — 通过橙色色调的中性色、留白布局、有温度的文案来传达温暖
|
||||
3. **Density with clarity** — 技术用户需要信息密度,但不能混乱
|
||||
4. **Community voice** — 设计应感觉是由使用者创造的,而非遥远的设计团队
|
||||
5. **Anthropic's shadow** — 遵循 Anthropic 的设计直觉:干净的布局、充足的间距、温暖的色温
|
||||
|
||||
### 品牌色
|
||||
|
||||
- 主色:Claude Orange `#D77757`(terra cotta)
|
||||
- 辅色:Claude Blue `#5769F7`
|
||||
- 暗色模式使用温暖的深色表面(非冷蓝黑色)
|
||||
|
||||
### 目标用户
|
||||
|
||||
技术团队/企业,在专业工作流中使用 AI 辅助编程。友好的开源社区氛围,非企业 SaaS 风格。
|
||||
|
||||
### 视觉参考
|
||||
|
||||
Anthropic 公司的设计风格 — 干净、考究、温暖的底色。大量留白,以排版为核心。避免 AI 产品常见的设计套路(渐变文字、玻璃态、霓虹色)。
|
||||
|
||||
205
docs/features/acp-link.md
Normal file
205
docs/features/acp-link.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# acp-link — ACP 代理服务器
|
||||
|
||||
> 源码目录:`packages/acp-link/`
|
||||
> PR: #292
|
||||
> 新增时间:2026-04-18
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
`acp-link` 是一个 ACP (Agent Client Protocol) 代理服务器,将 WebSocket 客户端桥接到 ACP agent 的 stdio 接口。它让 ACP agent(如 Claude Code)可以通过 WebSocket 远程访问,而不仅限于本地 stdio。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **WebSocket → stdio 桥接**:将浏览器/远程客户端的 WebSocket 连接转换为 ACP agent 的 stdin/stdout NDJSON 流
|
||||
- **会话管理**:创建、加载、恢复、列出、关闭会话
|
||||
- **权限审批流程**:客户端可远程审批 agent 的工具权限请求
|
||||
- **RCS 集成**:可与 Remote Control Server (RCS) 连接,将 ACP agent 注册到 RCS 并通过 Web UI 交互
|
||||
- **HTTPS 支持**:内置自签名证书生成,支持安全连接
|
||||
- **Token 认证**:自动生成或通过环境变量配置认证 token
|
||||
|
||||
## 二、架构
|
||||
|
||||
### 独立模式
|
||||
|
||||
```
|
||||
┌──────────────────┐ WebSocket ┌──────────────────┐ stdio/NDJSON ┌──────────────┐
|
||||
│ 浏览器/客户端 │ ◄──────────────►│ acp-link │ ◄────────────────►│ ACP Agent │
|
||||
│ (WS Client) │ ws://host:port │ (Proxy Server) │ spawn subprocess │ (Claude等) │
|
||||
└──────────────────┘ └──────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### RCS 集成模式
|
||||
|
||||
```
|
||||
┌──────────────┐ WebSocket ┌──────────────────┐ stdio/NDJSON ┌──────────────┐
|
||||
│ RCS Web UI │ ◄──────────────►│ Remote Control │ ◄─────────────────►│ acp-link │
|
||||
│ (/code/*) │ ACP Relay WS │ Server (RCS) │ ACP events │ + Agent │
|
||||
└──────────────┘ └──────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
packages/acp-link/
|
||||
├── src/
|
||||
│ ├── server.ts # 主服务器:WS 连接管理、会话管理、权限处理、消息桥接
|
||||
│ ├── rcs-upstream.ts # RCS 上游客户端:REST 注册 + WS identify 两步流程
|
||||
│ ├── cert.ts # TLS 证书生成(自签名)
|
||||
│ ├── logger.ts # 日志模块
|
||||
│ ├── types.ts # JSON-RPC 和 ACP 协议类型定义
|
||||
│ ├── cli/
|
||||
│ │ ├── bin.ts # CLI 入口
|
||||
│ │ ├── command.ts # 命令行参数解析
|
||||
│ │ ├── app.ts # 应用启动
|
||||
│ │ └── context.ts # 上下文配置
|
||||
│ └── __tests__/ # 测试(cert, server, types)
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## 三、安装与使用
|
||||
|
||||
### 基本用法
|
||||
|
||||
```bash
|
||||
# 直接运行(在 monorepo 中)
|
||||
# 注意:claude 本身不支持 ACP,需要用 ccb-bun --acp 启动 ACP agent
|
||||
bun packages/acp-link/src/cli/bin.ts ccb-bun -- --acp
|
||||
|
||||
# 指定端口和主机
|
||||
acp-link --port 9000 --host 0.0.0.0 ccb-bun -- --acp
|
||||
|
||||
# 启用 HTTPS(自签名证书)
|
||||
acp-link --https ccb-bun -- --acp
|
||||
|
||||
# 调试模式
|
||||
acp-link --debug ccb-bun -- --acp
|
||||
```
|
||||
|
||||
### CLI 参考
|
||||
|
||||
```
|
||||
USAGE
|
||||
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
|
||||
acp-link --help
|
||||
acp-link --version
|
||||
|
||||
FLAGS
|
||||
[--port] Port to listen on [default = 9315]
|
||||
[--host] Host to bind to [default = localhost]
|
||||
[--debug] Enable debug logging to file
|
||||
[--no-auth] Disable authentication (dangerous)
|
||||
[--https] Enable HTTPS with self-signed cert
|
||||
-h --help Print help information and exit
|
||||
-v --version Print version information and exit
|
||||
|
||||
ARGUMENTS
|
||||
command... Agent command followed by its arguments (e.g. "ccb-bun -- --acp")
|
||||
```
|
||||
|
||||
## 四、认证
|
||||
|
||||
默认启动时自动生成随机 token。客户端连接时需通过 query 参数传递:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws?token=<your-token>
|
||||
```
|
||||
|
||||
配置固定 token:
|
||||
|
||||
```bash
|
||||
ACP_AUTH_TOKEN=my-fixed-token acp-link ccb-bun -- --acp
|
||||
```
|
||||
|
||||
禁用认证(不推荐,仅用于开发):
|
||||
|
||||
```bash
|
||||
acp-link --no-auth ccb-bun -- --acp
|
||||
```
|
||||
|
||||
## 五、RCS 集成
|
||||
|
||||
acp-link 支持将 ACP agent 注册到 Remote Control Server,通过 Web UI 远程操控。
|
||||
|
||||
### 连接方式
|
||||
|
||||
```bash
|
||||
# 通过环境变量配置 RCS 连接
|
||||
ACP_RCS_URL=http://localhost:3000 \
|
||||
ACP_RCS_TOKEN=sk-rcs-your-key \
|
||||
ACP_RCS_NAME=my-agent \
|
||||
acp-link ccb-bun -- --acp
|
||||
```
|
||||
|
||||
### 注册流程(两步)
|
||||
|
||||
1. **REST 注册**:通过 `POST /v1/environments/bridge` 向 RCS 注册环境
|
||||
2. **WS identify**:建立 WebSocket 连接后发送 `identify` 消息(携带 agentId),替代完整 `register`
|
||||
|
||||
```
|
||||
acp-link RCS
|
||||
│ │
|
||||
│── POST /v1/environments/bridge ──►│ (REST 注册)
|
||||
│◄── { agentId, sessionId } ───────│
|
||||
│ │
|
||||
│── WS connect ─────────────────►│ (WebSocket)
|
||||
│── identify { agentId } ────────►│ (WS 标识)
|
||||
│◄── registered ─────────────────│
|
||||
│ │
|
||||
│── ACP events ─────────────────►│ (双向消息转发)
|
||||
│◄── user prompts/permissions ───│
|
||||
```
|
||||
|
||||
## 六、权限模式
|
||||
|
||||
### permissionMode 传递链
|
||||
|
||||
权限模式通过整条链路传递:Web UI → RCS → acp-link → ACP agent。
|
||||
|
||||
支持的权限模式:
|
||||
- `default` — 每次请求权限确认
|
||||
- `auto` — 自动判断
|
||||
- `acceptEdits` — 自动接受编辑
|
||||
- `plan` — 规划模式
|
||||
- `dontAsk` — 不询问
|
||||
- `bypassPermissions` — 绕过权限(需 sandbox 环境)
|
||||
|
||||
### fallback 链
|
||||
|
||||
当客户端未显式传递 permissionMode 时,使用以下 fallback 链:
|
||||
|
||||
```
|
||||
客户端传值 > config.permissionMode > ACP_PERMISSION_MODE 环境变量
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
ACP_PERMISSION_MODE=auto acp-link ccb-bun -- --acp
|
||||
```
|
||||
|
||||
## 七、权限管道(2026-04-18 改进)
|
||||
|
||||
### 模式同步
|
||||
|
||||
`applySessionMode` 在 agent 切换权限模式时同步 `appState.toolPermissionContext.mode`,确保内部权限上下文与 ACP 客户端状态一致。
|
||||
|
||||
### 统一权限流水线
|
||||
|
||||
`createAcpCanUseTool` 接入 `hasPermissionsToUseTool` 统一权限流水线,替代原来分散的处理逻辑。支持 `onModeChange` 回调,模式变更时实时同步。
|
||||
|
||||
### bypass 检测
|
||||
|
||||
`bypassPermissions` 模式增加可用性检测 — 仅在非 root 或 sandbox 环境中允许启用,防止权限绕过的安全风险。
|
||||
|
||||
## 八、环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `ACP_AUTH_TOKEN` | 固定认证 token(默认自动生成) |
|
||||
| `ACP_PERMISSION_MODE` | 默认权限模式 fallback |
|
||||
| `ACP_RCS_URL` | RCS 服务器地址(启用 RCS 集成) |
|
||||
| `ACP_RCS_TOKEN` | RCS API token |
|
||||
| `ACP_RCS_NAME` | Agent 名称(在 RCS 中显示) |
|
||||
| `ACP_RCS_CHANNEL_GROUP` | Channel group ID |
|
||||
| `ACP_MAX_SESSIONS` | 最大会话数 |
|
||||
@@ -13,17 +13,22 @@
|
||||
┌──────────────────┐ HTTP/SSE │ │ In-Memory │ │
|
||||
│ Web UI 控制面板 │ ◄─────────────── │ │ Store │ │
|
||||
│ (/code/*) │ │ └──────────────┘ │
|
||||
└──────────────────┘ │ ┌──────────────┐ │
|
||||
│ │ JWT Auth │ │
|
||||
│ (React + Vite) │ │ ┌──────────────┐ │
|
||||
└──────────────────┘ │ │ JWT Auth │ │
|
||||
│ └──────────────┘ │
|
||||
└──────────────────────┘
|
||||
┌──────────────────┐ │ ┌──────────────┐ │
|
||||
│ acp-link │ ◄── ACP Relay ─── │ │ ACP Handler │ │
|
||||
│ + ACP Agent │ WebSocket │ └──────────────┘ │
|
||||
└──────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
**RCS 是一个纯内存的中间服务**,它的职责是:
|
||||
- 接收 Claude Code CLI 的环境注册和工作轮询
|
||||
- 接收 acp-link 的 ACP agent 注册,支持 WebSocket relay 桥接
|
||||
- 提供 Web UI 供操作者远程监控和审批
|
||||
- 通过 WebSocket/SSE 双向传输消息
|
||||
- 管理会话、环境、权限请求
|
||||
- 提供 ACP SSE event stream 供外部消费者订阅 channel group 事件
|
||||
|
||||
## 前置条件
|
||||
|
||||
@@ -169,17 +174,70 @@ claude bridge
|
||||
|
||||
## Web UI 控制面板
|
||||
|
||||
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。功能:
|
||||
通过 `/remote-control` 命令获取 URL 后,在浏览器打开即可使用。
|
||||
|
||||
- 查看已注册的运行环境(environment 模式)
|
||||
### 技术栈(v2,2026-04-18 重构)
|
||||
|
||||
Web UI 已从原生 JS 重构为 **React + Vite + Radix UI**:
|
||||
|
||||
- **框架**: React 19 + Vite 构建,TypeScript
|
||||
- **UI 组件**: Radix UI primitives(Dialog、Tabs、Select、Popover 等)
|
||||
- **聊天组件**: 完整的 ACP 聊天界面,支持 Plan 可视化、工具调用展示、权限审批
|
||||
- **AI Elements**: 独立的 AI 交互组件库(message、reasoning、tool、code-block、prompt-input 等)
|
||||
- **ACP 直连**: 支持 QR 码扫描自动跳转 ACP 直连视图(`ACPDirectView`)
|
||||
- **主题系统**: 暗色/亮色主题切换,遵循 Impeccable 设计系统
|
||||
|
||||
### 功能
|
||||
|
||||
- 查看已注册的运行环境(environment 模式),区分 ACP Agent 和 Claude Code 类型
|
||||
- 创建和管理会话
|
||||
- 实时查看对话消息和工具调用
|
||||
- 查看 Autopilot 状态(`standby` / `sleeping`)和自动运行指示
|
||||
- 查看 authoritative task snapshots 驱动的 Tasks 面板
|
||||
- 审批 Claude Code 的工具权限请求
|
||||
- 权限模式选择器(6 种模式:默认/自动接受编辑/跳过权限/规划/不询问/自动判断)
|
||||
- 模型选择器(可选可用模型)
|
||||
- Plan 可视化(进度条、状态图标、优先级标签)
|
||||
- ACP QR 扫描自动跳转到 ACP 聊天界面
|
||||
|
||||
Web UI 使用 UUID 认证(无需用户账户),适合受信任网络环境。
|
||||
|
||||
## ACP 支持
|
||||
|
||||
RCS 支持 ACP (Agent Client Protocol) agent 通过 `acp-link` 包接入。
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
acp-link ──REST注册──► RCS POST /v1/environments/bridge
|
||||
acp-link ──WS identify──► RCS WebSocket (携带 agentId)
|
||||
acp-link ◄──ACP relay──► RCS ◄──Web UI WS──► 浏览器
|
||||
```
|
||||
|
||||
### 后端组件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/routes/acp/index.ts` | ACP REST 路由:agents 列表、channel groups、relay |
|
||||
| `src/transport/acp-ws-handler.ts` | ACP WebSocket 处理:agent 注册、心跳、消息转发 |
|
||||
| `src/transport/acp-relay-handler.ts` | 前端 WS → acp-link 透传 + EventBus inbound 转发 |
|
||||
| `src/transport/acp-sse-writer.ts` | SSE event stream 供外部消费者订阅 |
|
||||
|
||||
### acp-link 连接
|
||||
|
||||
详见 [acp-link 文档](./acp-link.md)。
|
||||
|
||||
```bash
|
||||
# 在 RCS 环境中启动 acp-link
|
||||
# 注意:claude 本身不支持 ACP,需要用 ccb-bun --acp
|
||||
ACP_RCS_URL=http://localhost:3000 \
|
||||
ACP_RCS_TOKEN=sk-rcs-your-key \
|
||||
ACP_RCS_NAME=my-agent \
|
||||
acp-link ccb-bun -- --acp
|
||||
```
|
||||
|
||||
ACP session 在 Web UI 中显示紫色标签,与普通 Claude Code session 区分。
|
||||
|
||||
## 工作流程详解
|
||||
|
||||
```
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"docs/features/voice-mode",
|
||||
"docs/features/bridge-mode",
|
||||
"docs/features/remote-control-self-hosting",
|
||||
"docs/features/acp-link",
|
||||
"docs/features/proactive",
|
||||
"docs/features/ultraplan"
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-best",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||
"type": "module",
|
||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "acp-link",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
|
||||
"author": "claude-code-best",
|
||||
"type": "module",
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ServerConfig {
|
||||
debug?: boolean;
|
||||
token?: string;
|
||||
https?: boolean;
|
||||
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
|
||||
permissionMode?: string;
|
||||
}
|
||||
|
||||
// Pending permission request
|
||||
@@ -85,6 +87,7 @@ let AGENT_CWD: string;
|
||||
let SERVER_PORT: number;
|
||||
let SERVER_HOST: string;
|
||||
let AUTH_TOKEN: string | undefined;
|
||||
let DEFAULT_PERMISSION_MODE: string | undefined;
|
||||
|
||||
const clients = new Map<WSContext, ClientState>();
|
||||
|
||||
@@ -318,7 +321,7 @@ async function handleConnect(ws: WSContext): Promise<void> {
|
||||
|
||||
async function handleNewSession(
|
||||
ws: WSContext,
|
||||
params: { cwd?: string },
|
||||
params: { cwd?: string; permissionMode?: string },
|
||||
): Promise<void> {
|
||||
const state = clients.get(ws);
|
||||
if (!state?.connection) {
|
||||
@@ -329,9 +332,11 @@ async function handleNewSession(
|
||||
|
||||
try {
|
||||
const sessionCwd = params.cwd || AGENT_CWD;
|
||||
const permissionMode = params.permissionMode || DEFAULT_PERMISSION_MODE;
|
||||
const result = await state.connection.newSession({
|
||||
cwd: sessionCwd,
|
||||
mcpServers: [],
|
||||
...(permissionMode ? { _meta: { permissionMode } } : {}),
|
||||
});
|
||||
|
||||
state.sessionId = result.sessionId;
|
||||
@@ -598,6 +603,7 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
SERVER_PORT = port;
|
||||
SERVER_HOST = host;
|
||||
AUTH_TOKEN = token;
|
||||
DEFAULT_PERMISSION_MODE = config.permissionMode || process.env.ACP_PERMISSION_MODE;
|
||||
|
||||
// Initialize RCS upstream client if configured
|
||||
const rcsUrl = process.env.ACP_RCS_URL;
|
||||
@@ -634,7 +640,7 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
handleDisconnect(relayWs);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(relayWs, (msg.payload as { cwd?: string }) || {});
|
||||
await handleNewSession(relayWs, (msg.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(relayWs, msg.payload as { content: ContentBlock[] });
|
||||
@@ -734,7 +740,7 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
||||
handleDisconnect(ws);
|
||||
break;
|
||||
case "new_session":
|
||||
await handleNewSession(ws, (data.payload as { cwd?: string }) || {});
|
||||
await handleNewSession(ws, (data.payload as { cwd?: string; permissionMode?: string }) || {});
|
||||
break;
|
||||
case "prompt":
|
||||
await handlePrompt(ws, data.payload as { content: ContentBlock[] });
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
getAutomationStateSnapshot,
|
||||
getAutomationStateEventPayload,
|
||||
automationStatesEqual,
|
||||
} from "../services/automationState";
|
||||
import type { AutomationStateResponse } from "../types/api";
|
||||
|
||||
// =============================================================================
|
||||
// normalizeAutomationState (via getAutomationStateSnapshot)
|
||||
// =============================================================================
|
||||
|
||||
describe("normalizeAutomationState", () => {
|
||||
test("returns undefined when metadata has no automation_state key", () => {
|
||||
expect(getAutomationStateSnapshot({})).toBeUndefined();
|
||||
expect(getAutomationStateSnapshot({ other: true })).toBeUndefined();
|
||||
expect(getAutomationStateSnapshot(null)).toBeUndefined();
|
||||
expect(getAutomationStateSnapshot(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns disabled state for null automation_state", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: null });
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns disabled state for non-object automation_state", () => {
|
||||
for (const val of ["string", 123, true, []]) {
|
||||
const result = getAutomationStateSnapshot({ automation_state: val });
|
||||
expect(result?.enabled).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizes enabled: true correctly", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true } });
|
||||
expect(result?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizes enabled to false for non-true values", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: "yes" } });
|
||||
expect(result?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts phase: standby", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "standby" } });
|
||||
expect(result?.phase).toBe("standby");
|
||||
});
|
||||
|
||||
test("accepts phase: sleeping", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "sleeping" } });
|
||||
expect(result?.phase).toBe("sleeping");
|
||||
});
|
||||
|
||||
test("rejects invalid phase values", () => {
|
||||
for (const phase of ["running", "idle", "active", "", null]) {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase } });
|
||||
expect(result?.phase).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizes next_tick_at as number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: 12345 } });
|
||||
expect(result?.next_tick_at).toBe(12345);
|
||||
});
|
||||
|
||||
test("normalizes next_tick_at as null for non-number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: "soon" } });
|
||||
expect(result?.next_tick_at).toBeNull();
|
||||
});
|
||||
|
||||
test("normalizes sleep_until as number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: 99999 } });
|
||||
expect(result?.sleep_until).toBe(99999);
|
||||
});
|
||||
|
||||
test("normalizes sleep_until as null for non-number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: false } });
|
||||
expect(result?.sleep_until).toBeNull();
|
||||
});
|
||||
|
||||
test("fully normalizes a complete valid state", () => {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: { enabled: true, phase: "sleeping", next_tick_at: 100, sleep_until: 200 },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
next_tick_at: 100,
|
||||
sleep_until: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// getAutomationStateEventPayload
|
||||
// =============================================================================
|
||||
|
||||
describe("getAutomationStateEventPayload", () => {
|
||||
test("returns disabled default when no automation_state in metadata", () => {
|
||||
const result = getAutomationStateEventPayload({});
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns disabled default for null metadata", () => {
|
||||
const result = getAutomationStateEventPayload(null);
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns normalized state when automation_state present", () => {
|
||||
const result = getAutomationStateEventPayload({
|
||||
automation_state: { enabled: true, phase: "standby", next_tick_at: 50, sleep_until: 60 },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 50,
|
||||
sleep_until: 60,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a new object each call (not frozen reference)", () => {
|
||||
const a = getAutomationStateEventPayload({});
|
||||
const b = getAutomationStateEventPayload({});
|
||||
expect(a).toEqual(b);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// automationStatesEqual
|
||||
// =============================================================================
|
||||
|
||||
describe("automationStatesEqual", () => {
|
||||
const base: AutomationStateResponse = {
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
next_tick_at: 100,
|
||||
sleep_until: 200,
|
||||
};
|
||||
|
||||
test("returns true for identical states", () => {
|
||||
expect(automationStatesEqual(base, { ...base })).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when enabled differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, enabled: false })).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when phase differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, phase: "sleeping" })).toBe(false);
|
||||
expect(automationStatesEqual(base, { ...base, phase: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when next_tick_at differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, next_tick_at: 999 })).toBe(false);
|
||||
expect(automationStatesEqual(base, { ...base, next_tick_at: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when sleep_until differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, sleep_until: 999 })).toBe(false);
|
||||
expect(automationStatesEqual(base, { ...base, sleep_until: null })).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when both are disabled defaults", () => {
|
||||
const disabled: AutomationStateResponse = { enabled: false, phase: null, next_tick_at: null, sleep_until: null };
|
||||
expect(automationStatesEqual(disabled, { ...disabled })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { toClientPayload } from "../transport/client-payload";
|
||||
import type { SessionEvent } from "../transport/event-bus";
|
||||
|
||||
function makeEvent(overrides: Partial<SessionEvent> & Pick<SessionEvent, "type" | "sessionId">): SessionEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
payload: null,
|
||||
direction: "inbound",
|
||||
seqNum: 1,
|
||||
createdAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// user / user_message
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — user message", () => {
|
||||
test("maps user type with content", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-1",
|
||||
payload: { content: "hello" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("user");
|
||||
expect(result.session_id).toBe("sess-1");
|
||||
expect((result as any).message.role).toBe("user");
|
||||
expect((result as any).message.content).toBe("hello");
|
||||
});
|
||||
|
||||
test("maps user_message type same as user", () => {
|
||||
const event = makeEvent({
|
||||
type: "user_message",
|
||||
sessionId: "sess-2",
|
||||
payload: { content: "world" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("user");
|
||||
expect(result.session_id).toBe("sess-2");
|
||||
});
|
||||
|
||||
test("falls back to message field when content is missing", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-3",
|
||||
payload: { message: "fallback msg" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).message.content).toBe("fallback msg");
|
||||
});
|
||||
|
||||
test("falls back to empty string when both content and message missing", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-4",
|
||||
payload: {},
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).message.content).toBe("");
|
||||
});
|
||||
|
||||
test("includes isSynthetic when true", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-5",
|
||||
payload: { content: "auto", isSynthetic: true },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("does not include isSynthetic when false", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-6",
|
||||
payload: { content: "manual", isSynthetic: false },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).isSynthetic).toBeUndefined();
|
||||
});
|
||||
|
||||
test("uses payload.uuid when present", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-7",
|
||||
payload: { content: "hi", uuid: "custom-uuid" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.uuid).toBe("custom-uuid");
|
||||
});
|
||||
|
||||
test("falls back to event.id when payload.uuid is missing", () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-8",
|
||||
payload: { content: "hi" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.uuid).toBe("evt-1");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// permission_response / control_response
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — permission response", () => {
|
||||
test("approved=true maps to allow behavior", () => {
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-1",
|
||||
payload: { approved: true, request_id: "req-1" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_response");
|
||||
const resp = (result as any).response;
|
||||
expect(resp.subtype).toBe("success");
|
||||
expect(resp.request_id).toBe("req-1");
|
||||
expect(resp.response.behavior).toBe("allow");
|
||||
});
|
||||
|
||||
test("approved=false maps to deny behavior with error", () => {
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-2",
|
||||
payload: { approved: false, request_id: "req-2" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_response");
|
||||
const resp = (result as any).response;
|
||||
expect(resp.subtype).toBe("error");
|
||||
expect(resp.error).toBe("Permission denied by user");
|
||||
expect(resp.response.behavior).toBe("deny");
|
||||
});
|
||||
|
||||
test("approved=false includes feedback message when provided", () => {
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-3",
|
||||
payload: { approved: false, request_id: "req-3", message: "please revise" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).response.message).toBe("please revise");
|
||||
});
|
||||
|
||||
test("passes through existingResponse directly", () => {
|
||||
const existingResponse = { subtype: "success", custom: true };
|
||||
const event = makeEvent({
|
||||
type: "control_response",
|
||||
sessionId: "sess-4",
|
||||
payload: { approved: true, response: existingResponse },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_response");
|
||||
expect((result as any).response).toBe(existingResponse);
|
||||
});
|
||||
|
||||
test("includes updatedInput when approved with updated_input", () => {
|
||||
const updatedInput = { file_path: "/new/path" };
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-5",
|
||||
payload: { approved: true, request_id: "req-5", updated_input: updatedInput },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).response.response.updatedInput).toEqual(updatedInput);
|
||||
});
|
||||
|
||||
test("includes updatedPermissions when approved with updated_permissions", () => {
|
||||
const perms = [{ type: "allow", tool: "bash" }];
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-6",
|
||||
payload: { approved: true, request_id: "req-6", updated_permissions: perms },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).response.response.updatedPermissions).toEqual(perms);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// interrupt
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — interrupt", () => {
|
||||
test("maps interrupt to control_request with subtype interrupt", () => {
|
||||
const event = makeEvent({
|
||||
type: "interrupt",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_request");
|
||||
expect((result as any).request_id).toBe("evt-1");
|
||||
expect((result as any).request.subtype).toBe("interrupt");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// control_request
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — control_request", () => {
|
||||
test("passes through request_id and request from payload", () => {
|
||||
const event = makeEvent({
|
||||
type: "control_request",
|
||||
sessionId: "sess-1",
|
||||
payload: { request_id: "req-99", request: { subtype: "permission", tool: "bash" } },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_request");
|
||||
expect((result as any).request_id).toBe("req-99");
|
||||
expect((result as any).request.subtype).toBe("permission");
|
||||
});
|
||||
|
||||
test("falls back request to payload when no request field", () => {
|
||||
const event = makeEvent({
|
||||
type: "control_request",
|
||||
sessionId: "sess-2",
|
||||
payload: { request_id: "req-10", custom: "data" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).request).toEqual({ request_id: "req-10", custom: "data" });
|
||||
});
|
||||
|
||||
test("falls back request_id to event.id when missing", () => {
|
||||
const event = makeEvent({
|
||||
type: "control_request",
|
||||
sessionId: "sess-3",
|
||||
payload: { request: { subtype: "test" } },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).request_id).toBe("evt-1");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// default fallback
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — default types", () => {
|
||||
test("passes through unknown type with type/uuid/session_id/message", () => {
|
||||
const event = makeEvent({
|
||||
type: "assistant",
|
||||
sessionId: "sess-1",
|
||||
payload: { uuid: "u-1", content: "response text" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("assistant");
|
||||
expect(result.uuid).toBe("u-1");
|
||||
expect(result.session_id).toBe("sess-1");
|
||||
expect(result.message).toEqual({ uuid: "u-1", content: "response text" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
|
||||
const { normalizePayload } = await import("../services/transport");
|
||||
|
||||
// extractContent is not exported; we test it via normalizePayload's content field
|
||||
|
||||
// =============================================================================
|
||||
// extractContent (via normalizePayload content field)
|
||||
// =============================================================================
|
||||
|
||||
describe("extractContent", () => {
|
||||
test("returns empty string for null payload", () => {
|
||||
const result = normalizePayload("assistant", null);
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string for undefined payload", () => {
|
||||
const result = normalizePayload("assistant", undefined);
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
test("returns the string for string payload", () => {
|
||||
const result = normalizePayload("assistant", "hello world");
|
||||
expect(result.content).toBe("hello world");
|
||||
});
|
||||
|
||||
test("extracts content field from object payload", () => {
|
||||
const result = normalizePayload("assistant", { content: "direct content" });
|
||||
expect(result.content).toBe("direct content");
|
||||
});
|
||||
|
||||
test("extracts message.content string from object payload", () => {
|
||||
const result = normalizePayload("assistant", { message: { content: "msg content" } });
|
||||
expect(result.content).toBe("msg content");
|
||||
});
|
||||
|
||||
test("extracts text blocks from message.content array", () => {
|
||||
const payload = {
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "Hello " },
|
||||
{ type: "text", text: "World" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = normalizePayload("assistant", payload);
|
||||
expect(result.content).toBe("Hello World");
|
||||
});
|
||||
|
||||
test("ignores non-text blocks in message.content array", () => {
|
||||
const payload = {
|
||||
message: {
|
||||
content: [
|
||||
{ type: "image", url: "http://example.com/img.png" },
|
||||
{ type: "text", text: "only this" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = normalizePayload("assistant", payload);
|
||||
expect(result.content).toBe("only this");
|
||||
});
|
||||
|
||||
test("returns empty string when no extractable content", () => {
|
||||
const result = normalizePayload("assistant", { foo: "bar" });
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
|
||||
test("prefers direct content over message.content", () => {
|
||||
const result = normalizePayload("assistant", { content: "direct", message: { content: "nested" } });
|
||||
expect(result.content).toBe("direct");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// normalizePayload — field preservation
|
||||
// =============================================================================
|
||||
|
||||
describe("normalizePayload — field preservation", () => {
|
||||
test("preserves raw payload", () => {
|
||||
const payload = { content: "test", extra: true };
|
||||
const result = normalizePayload("assistant", payload);
|
||||
expect(result.raw).toBe(payload);
|
||||
});
|
||||
|
||||
test("preserves uuid field", () => {
|
||||
const result = normalizePayload("assistant", { uuid: "u-123" });
|
||||
expect(result.uuid).toBe("u-123");
|
||||
});
|
||||
|
||||
test("does not preserve uuid when empty string", () => {
|
||||
const result = normalizePayload("assistant", { uuid: "" });
|
||||
expect(result.uuid).toBeUndefined();
|
||||
});
|
||||
|
||||
test("preserves isSynthetic boolean", () => {
|
||||
const result = normalizePayload("assistant", { isSynthetic: true });
|
||||
expect(result.isSynthetic).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves status string", () => {
|
||||
const result = normalizePayload("assistant", { status: "running" });
|
||||
expect(result.status).toBe("running");
|
||||
});
|
||||
|
||||
test("preserves subtype string", () => {
|
||||
const result = normalizePayload("assistant", { subtype: "progress" });
|
||||
expect(result.subtype).toBe("progress");
|
||||
});
|
||||
|
||||
test("preserves tool_name from tool_name field", () => {
|
||||
const result = normalizePayload("tool", { tool_name: "bash" });
|
||||
expect(result.tool_name).toBe("bash");
|
||||
});
|
||||
|
||||
test("preserves tool_name from name field", () => {
|
||||
const result = normalizePayload("tool", { name: "read" });
|
||||
expect(result.tool_name).toBe("read");
|
||||
});
|
||||
|
||||
test("preserves tool_input from tool_input field", () => {
|
||||
const input = { command: "ls" };
|
||||
const result = normalizePayload("tool", { tool_input: input });
|
||||
expect(result.tool_input).toEqual(input);
|
||||
});
|
||||
|
||||
test("preserves tool_input from input field", () => {
|
||||
const input = { path: "/tmp" };
|
||||
const result = normalizePayload("tool", { input });
|
||||
expect(result.tool_input).toEqual(input);
|
||||
});
|
||||
|
||||
test("preserves request_id", () => {
|
||||
const result = normalizePayload("permission", { request_id: "req-1" });
|
||||
expect(result.request_id).toBe("req-1");
|
||||
});
|
||||
|
||||
test("preserves request object", () => {
|
||||
const req = { subtype: "permission" };
|
||||
const result = normalizePayload("permission", { request: req });
|
||||
expect(result.request).toEqual(req);
|
||||
});
|
||||
|
||||
test("preserves approved field", () => {
|
||||
const result = normalizePayload("permission", { approved: true });
|
||||
expect(result.approved).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves updated_input", () => {
|
||||
const input = { command: "rm -rf" };
|
||||
const result = normalizePayload("permission", { updated_input: input });
|
||||
expect(result.updated_input).toEqual(input);
|
||||
});
|
||||
|
||||
test("preserves message field for backward compat", () => {
|
||||
const msg = { role: "user", content: "hi" };
|
||||
const result = normalizePayload("assistant", { message: msg });
|
||||
expect(result.message).toEqual(msg);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// normalizePayload — task_state special handling
|
||||
// =============================================================================
|
||||
|
||||
describe("normalizePayload — task_state type", () => {
|
||||
test("preserves task_list_id (snake_case)", () => {
|
||||
const result = normalizePayload("task_state", { task_list_id: "tl-1" });
|
||||
expect(result.task_list_id).toBe("tl-1");
|
||||
});
|
||||
|
||||
test("preserves taskListId (camelCase)", () => {
|
||||
const result = normalizePayload("task_state", { taskListId: "tl-2" });
|
||||
expect(result.taskListId).toBe("tl-2");
|
||||
});
|
||||
|
||||
test("preserves tasks array", () => {
|
||||
const tasks = [{ id: "t1", title: "Task 1" }];
|
||||
const result = normalizePayload("task_state", { tasks });
|
||||
expect(result.tasks).toEqual(tasks);
|
||||
});
|
||||
|
||||
test("does not preserve task fields for non-task_state type", () => {
|
||||
const result = normalizePayload("assistant", { task_list_id: "tl-1", taskListId: "tl-2", tasks: [] });
|
||||
expect(result.task_list_id).toBeUndefined();
|
||||
expect(result.taskListId).toBeUndefined();
|
||||
expect(result.tasks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,13 +7,14 @@ import { MessageSquare, Plus, PanelLeftClose, PanelLeft } from "lucide-react";
|
||||
|
||||
interface ACPMainProps {
|
||||
client: ACPClient;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main container — Anthropic sidebar + chat layout.
|
||||
* Sidebar: sectioned by recency, orange active state, warm raised bg.
|
||||
*/
|
||||
export function ACPMain({ client }: ACPMainProps) {
|
||||
export function ACPMain({ client, agentId }: ACPMainProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
// Handle session selection
|
||||
@@ -36,16 +37,16 @@ export function ACPMain({ client }: ACPMainProps) {
|
||||
{/* 侧边栏 — Anthropic warm sidebar, hidden on mobile */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex flex-col border-r border-border bg-surface-1 transition-all duration-200 flex-shrink-0",
|
||||
"hidden md:flex flex-col border-r border-border/60 bg-surface-1/50 transition-all duration-200 flex-shrink-0",
|
||||
sidebarCollapsed ? "w-12" : "w-64",
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-3 border-b border-border">
|
||||
<div className="flex items-center justify-between px-3 py-4">
|
||||
{!sidebarCollapsed && (
|
||||
<span className="text-xs font-display font-medium text-text-muted uppercase tracking-wider px-1">会话</span>
|
||||
<span className="text-xs font-display font-semibold text-text-muted uppercase tracking-widest px-1">会话</span>
|
||||
)}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className={cn("flex items-center gap-0.5", sidebarCollapsed && "mx-auto")}>
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -82,7 +83,7 @@ export function ACPMain({ client }: ACPMainProps) {
|
||||
|
||||
{/* 聊天区域 */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<ChatInterface client={client} />
|
||||
<ChatInterface client={client} agentId={agentId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -170,11 +171,12 @@ function SidebarSessionList({
|
||||
const groups = groupByRecency(sorted);
|
||||
|
||||
return (
|
||||
<nav className="py-2" aria-label="历史会话">
|
||||
{groups.map((group) => (
|
||||
<nav className="py-1" aria-label="历史会话">
|
||||
{groups.map((group, gi) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-[10px] font-display font-medium uppercase tracking-widest text-text-muted">
|
||||
{gi > 0 && <div className="mx-3 my-2 border-t border-border/40" />}
|
||||
<div className="px-4 py-2">
|
||||
<span className="text-[10px] font-display font-semibold uppercase tracking-widest text-text-muted/70">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -187,15 +189,15 @@ function SidebarSessionList({
|
||||
onSelectSession(session);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
|
||||
"w-full flex items-center gap-2.5 px-4 py-2 text-left transition-colors rounded-none",
|
||||
session.sessionId === activeId
|
||||
? "bg-brand/10 text-text-primary border-l-2 border-l-brand"
|
||||
: "text-text-secondary hover:bg-surface-1/50 hover:text-text-primary border-l-2 border-l-transparent",
|
||||
? "bg-brand/8 text-text-primary"
|
||||
: "text-text-secondary hover:bg-surface-2/60 hover:text-text-primary",
|
||||
)}
|
||||
title={session.title || session.sessionId}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 text-text-muted" />
|
||||
<span className="text-sm font-display truncate">
|
||||
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 opacity-50" />
|
||||
<span className="text-[13px] font-display truncate leading-snug">
|
||||
{session.title && session.title.trim() ? session.title : "新会话"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import imageCompression from "browser-image-compression";
|
||||
import type { ACPClient } from "../src/acp/client";
|
||||
import type { SessionUpdate, PermissionRequestPayload, PermissionOption, ContentBlock, ImageContent } from "../src/acp/types";
|
||||
import type { ThreadEntry, ToolCallStatus, ToolCallData, UserMessageImage, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, ChatInputMessage, PendingPermission } from "../src/lib/types";
|
||||
import type { ThreadEntry, ToolCallStatus, ToolCallData, UserMessageImage, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, ChatInputMessage, PendingPermission, PlanDisplayEntry } from "../src/lib/types";
|
||||
import { ChatView } from "./chat/ChatView";
|
||||
import { ChatInput } from "./chat/ChatInput";
|
||||
import { PermissionPanel } from "./chat/PermissionPanel";
|
||||
@@ -44,13 +44,14 @@ function dataUrlToBlob(dataUrl: string): Blob {
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
}
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Shield, ChevronDown, ChevronUp, Check } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
// =============================================================================
|
||||
// Type Definitions - imported from shared types module
|
||||
@@ -58,6 +59,72 @@ import {
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
client: ACPClient;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Permission Mode Selector
|
||||
// =============================================================================
|
||||
|
||||
const PERMISSION_MODES = [
|
||||
{ value: "default", label: "默认", description: "手动审批权限请求" },
|
||||
{ value: "acceptEdits", label: "自动接受编辑", description: "自动允许文件编辑操作" },
|
||||
{ value: "bypassPermissions", label: "跳过权限", description: "跳过所有权限检查" },
|
||||
{ value: "plan", label: "规划模式", description: "仅规划,不执行工具" },
|
||||
{ value: "dontAsk", label: "不询问", description: "不弹出询问,自动拒绝" },
|
||||
{ value: "auto", label: "自动判断", description: "AI 自动判断是否批准" },
|
||||
] as const;
|
||||
|
||||
function PermissionModeSelector({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: string;
|
||||
onModeChange: (mode: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const current = PERMISSION_MODES.find((m) => m.value === mode) ?? PERMISSION_MODES[0];
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground h-7 px-2"
|
||||
>
|
||||
<Shield className="h-3 w-3" />
|
||||
<span className="max-w-24 truncate">{current.label}</span>
|
||||
{open ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-1" align="start">
|
||||
{PERMISSION_MODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onModeChange(m.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
<span className="mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
||||
{mode === m.value && <Check className="h-3.5 w-3.5 text-brand" />}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-text-primary">{m.label}</div>
|
||||
<div className="text-xs text-text-muted">{m.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -86,7 +153,7 @@ function findToolCallIndex(entries: ThreadEntry[], toolCallId: string): number {
|
||||
// ChatInterface Component
|
||||
// =============================================================================
|
||||
|
||||
export function ChatInterface({ client }: ChatInterfaceProps) {
|
||||
export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
// Flat list of entries (like Zed's entries: Vec<AgentThreadEntry>)
|
||||
const [entries, setEntries] = useState<ThreadEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -95,6 +162,7 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
|
||||
const activeSessionIdRef = useRef<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [permissionMode, setPermissionMode] = useState(() => localStorage.getItem("acp_permission_mode") || "default");
|
||||
// Reference: Zed's supports_images() checks prompt_capabilities.image
|
||||
const [supportsImages, setSupportsImages] = useState(false);
|
||||
const { commands: availableCommands } = useCommands(client);
|
||||
@@ -109,6 +177,8 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
|
||||
setSessionReady(false);
|
||||
}, []);
|
||||
|
||||
const storageKey = agentId ? `acp_last_session_${agentId}` : null;
|
||||
|
||||
const activateSession = useCallback((sessionId: string, options?: { resetEntries?: boolean }) => {
|
||||
const shouldResetEntries = options?.resetEntries ?? true;
|
||||
if (shouldResetEntries) {
|
||||
@@ -118,8 +188,12 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
|
||||
setActiveSessionId(sessionId);
|
||||
setSessionReady(true);
|
||||
setSupportsImages(client.supportsImages);
|
||||
// Persist session ID for restoration on remount
|
||||
if (storageKey) {
|
||||
try { localStorage.setItem(storageKey, sessionId); } catch {}
|
||||
}
|
||||
console.log("[ChatInterface] Active session:", sessionId, "supportsImages:", client.supportsImages);
|
||||
}, [client]);
|
||||
}, [client, storageKey]);
|
||||
|
||||
// =============================================================================
|
||||
// Permission Request Handler
|
||||
@@ -384,6 +458,38 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
|
||||
});
|
||||
});
|
||||
}
|
||||
// Handle plan update (replace entire plan)
|
||||
else if (update.sessionUpdate === "plan") {
|
||||
setEntries((prev) => {
|
||||
// Empty entries → remove existing plan
|
||||
if (update.entries.length === 0) {
|
||||
return prev.filter((e) => e.type !== "plan");
|
||||
}
|
||||
|
||||
// Find last plan entry
|
||||
const lastPlanIndex = prev.reduce(
|
||||
(acc, entry, i) => (entry.type === "plan" ? i : acc),
|
||||
-1,
|
||||
);
|
||||
|
||||
if (lastPlanIndex >= 0) {
|
||||
// Update existing plan in place
|
||||
return prev.map((entry, index) =>
|
||||
index === lastPlanIndex
|
||||
? { ...entry, entries: update.entries }
|
||||
: entry,
|
||||
);
|
||||
}
|
||||
|
||||
// Create new plan entry
|
||||
const newPlanEntry: PlanDisplayEntry = {
|
||||
type: "plan",
|
||||
id: `plan-${Date.now()}`,
|
||||
entries: update.entries,
|
||||
};
|
||||
return [...prev, newPlanEntry];
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// =============================================================================
|
||||
@@ -429,8 +535,26 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
|
||||
errorTimerRef.current = setTimeout(() => setErrorMessage(null), 5000);
|
||||
});
|
||||
|
||||
// Create session
|
||||
client.createSession();
|
||||
// Restore last session or create a new one
|
||||
const lastSessionId = storageKey ? localStorage.getItem(storageKey) : null;
|
||||
if (lastSessionId && (client.supportsLoadSession || client.supportsResumeSession)) {
|
||||
console.log("[ChatInterface] Restoring session:", lastSessionId);
|
||||
const restore = async () => {
|
||||
try {
|
||||
if (client.supportsLoadSession) {
|
||||
await client.loadSession({ sessionId: lastSessionId });
|
||||
} else {
|
||||
await client.resumeSession({ sessionId: lastSessionId });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[ChatInterface] Failed to restore session, creating new one:", err);
|
||||
client.createSession(undefined, permissionMode);
|
||||
}
|
||||
};
|
||||
restore();
|
||||
} else {
|
||||
client.createSession(undefined, permissionMode);
|
||||
}
|
||||
return () => {
|
||||
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
|
||||
client.setSessionCreatedHandler(() => {});
|
||||
@@ -465,8 +589,8 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
|
||||
|
||||
// 3. Create new session (like Zed's initial_state -> connection.new_session())
|
||||
// The session_created handler will set sessionReady=true when ready
|
||||
client.createSession();
|
||||
}, [client, isLoading, resetThreadState]);
|
||||
client.createSession(undefined, permissionMode);
|
||||
}, [client, isLoading, resetThreadState, permissionMode]);
|
||||
|
||||
// Cancel handler - matches Zed's cancel() logic in acp_thread.rs
|
||||
// 1. Mark all pending/running/waiting_for_confirmation tool calls as canceled
|
||||
@@ -559,9 +683,36 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
|
||||
|
||||
// Handle permission respond for unified PermissionPanel
|
||||
const handlePermissionPanelRespond = useCallback((requestId: string, approved: boolean) => {
|
||||
const kind = approved ? "accept_once" : "reject_once";
|
||||
handlePermissionResponse(requestId, null, kind as PermissionOption["kind"] | null);
|
||||
}, [handlePermissionResponse]);
|
||||
// Find the matching permission request to get the real optionId
|
||||
const perm = pendingPermissions.find((p) => p.requestId === requestId);
|
||||
let optionId: string | null = null;
|
||||
let optionKind: PermissionOption["kind"] | null = null;
|
||||
|
||||
if (perm?.options && perm.options.length > 0) {
|
||||
if (approved) {
|
||||
// Pick the first allow option (prefer allow_once, then allow_always)
|
||||
const allowOpt = perm.options.find((o) => o.kind === "allow_once") ?? perm.options.find((o) => o.kind === "allow_always");
|
||||
if (allowOpt) {
|
||||
optionId = allowOpt.optionId;
|
||||
optionKind = allowOpt.kind;
|
||||
}
|
||||
} else {
|
||||
// Pick the first reject option
|
||||
const rejectOpt = perm.options.find((o) => o.kind === "reject_once") ?? perm.options.find((o) => o.kind === "reject_always");
|
||||
if (rejectOpt) {
|
||||
optionId = rejectOpt.optionId;
|
||||
optionKind = rejectOpt.kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no matching option found, use null (cancelled)
|
||||
if (!optionId) {
|
||||
optionKind = approved ? "allow_once" : "reject_once";
|
||||
}
|
||||
|
||||
handlePermissionResponse(requestId, optionId, optionKind);
|
||||
}, [handlePermissionResponse, pendingPermissions]);
|
||||
|
||||
// Handle ChatInput submit — convert ChatInputMessage to ContentBlock[]
|
||||
const handleChatInputSubmit = useCallback(async (message: ChatInputMessage) => {
|
||||
@@ -667,7 +818,7 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
|
||||
|
||||
{/* Error banner */}
|
||||
{errorMessage && (
|
||||
<div className="mx-auto max-w-3xl w-full px-4 pb-1">
|
||||
<div className="mx-auto max-w-3xl w-full px-4 sm:px-8 pb-1">
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300 flex items-center justify-between">
|
||||
<span>{errorMessage}</span>
|
||||
<button
|
||||
@@ -683,8 +834,11 @@ export function ChatInterface({ client }: ChatInterfaceProps) {
|
||||
|
||||
{/* Model selector + New thread + ChatInput */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 pb-1 flex items-center justify-between">
|
||||
<ModelSelectorPopover client={client} />
|
||||
<div className="max-w-3xl mx-auto w-full px-4 sm:px-8 pb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<PermissionModeSelector mode={permissionMode} onModeChange={(m: string) => { setPermissionMode(m); localStorage.setItem("acp_permission_mode", m); }} />
|
||||
<ModelSelectorPopover client={client} />
|
||||
</div>
|
||||
{entries.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ConversationContent = ({
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("mx-auto flex max-w-3xl flex-col gap-4 p-4 min-w-0", className)}
|
||||
className={cn("mx-auto flex max-w-3xl flex-col gap-2 px-4 py-8 sm:px-8 sm:py-12 min-w-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -49,18 +49,18 @@ export const ConversationEmptyState = ({
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
"flex size-full flex-col items-center justify-center gap-4 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{icon && <div className="text-text-muted">{icon}</div>}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-base font-display text-text-primary">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
<p className="text-text-muted text-sm leading-relaxed max-w-xs">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function ToolPermissionButtons({ requestId, options, onRespond, className
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("p-3 border-t border-warning-border/30 border-l-3 border-l-warning-border bg-warning-bg/50", className)}>
|
||||
<div className={cn("p-3 border-t border-warning-border/30 bg-warning-bg/50", className)}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ShieldAlertIcon className="size-4 text-warning-text" />
|
||||
<span className="text-xs font-medium text-warning-text">
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface ReasoningContextValue {
|
||||
@@ -77,8 +77,12 @@ export const Reasoning = memo(
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
// Respect prefers-reduced-motion: skip animation auto-close
|
||||
const prefersReducedMotion = typeof window !== "undefined"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
if (!prefersReducedMotion && defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
@@ -86,7 +90,7 @@ export const Reasoning = memo(
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed, prefersReducedMotion]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
export interface TextShimmerProps {
|
||||
@@ -23,37 +21,22 @@ const ShimmerComponent = ({
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
"relative inline-block text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -71,8 +71,12 @@ export function ChatInput({
|
||||
setShowCommandMenu(false);
|
||||
return;
|
||||
}
|
||||
// Let cmdk handle arrow keys and Enter for selection
|
||||
// Tab also closes the menu
|
||||
// Arrow keys and Enter are handled by CommandMenu via document-level listener
|
||||
// Don't submit or move cursor when menu is open
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
setShowCommandMenu(false);
|
||||
@@ -161,7 +165,7 @@ export function ChatInput({
|
||||
const canSend = (text.trim() || images.length > 0) && !disabled;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-3xl mx-auto px-3 sm:px-4 pb-4 pt-2", className)}>
|
||||
<div className={cn("w-full max-w-3xl mx-auto px-4 sm:px-8 pb-4 pt-2", className)}>
|
||||
<div className="relative">
|
||||
{/* Slash command menu — floating above input */}
|
||||
{showCommandMenu && commands && commands.length > 0 && (
|
||||
@@ -187,13 +191,14 @@ export function ChatInput({
|
||||
<div key={i} className="relative group">
|
||||
<img
|
||||
src={`data:${img.mimeType};base64,${img.data}`}
|
||||
alt="附件"
|
||||
alt={`Attached image ${i + 1}`}
|
||||
className="h-14 w-14 object-cover rounded-lg border border-border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute -top-1.5 -right-1.5 h-5 w-5 rounded-full bg-surface-2 border border-border flex items-center justify-center text-text-muted hover:text-text-primary text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute -top-1.5 -right-1.5 min-h-[32px] min-w-[32px] h-5 w-5 rounded-full bg-surface-2 border border-border flex items-center justify-center text-text-muted hover:text-text-primary text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Remove image ${i + 1}`}
|
||||
>
|
||||
{"\u00D7"}
|
||||
</button>
|
||||
@@ -214,6 +219,7 @@ export function ChatInput({
|
||||
disabled={disabled}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
<span className="sr-only">Attach file</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ThreadEntry, ToolCallEntry } from "../../src/lib/types";
|
||||
import type { ThreadEntry, ToolCallEntry, PlanDisplayEntry } from "../../src/lib/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { UserBubble, AssistantBubble } from "./MessageBubble";
|
||||
import { ToolCallGroup } from "./ToolCallGroup";
|
||||
import { PlanDisplay } from "./PlanView";
|
||||
import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButtons } from "../ai-elements/conversation";
|
||||
|
||||
// =============================================================================
|
||||
@@ -59,13 +60,13 @@ export function ChatView({
|
||||
|
||||
{/* 思考指示器 — Anthropic 打字动画 */}
|
||||
{showThinking && (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="w-7 h-7 rounded-lg bg-brand/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M7 2L12 12H2L7 2Z" fill="var(--color-brand)" opacity=".85" />
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="w-8 h-8 rounded-lg bg-brand/8 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="var(--color-brand)" fillRule="nonzero" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pt-1">
|
||||
<div className="flex items-center gap-1 pt-2">
|
||||
<span className="chat-typing-indicator" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
@@ -86,19 +87,23 @@ export function ChatView({
|
||||
|
||||
function entrySpacing(entries: ThreadEntry[], index: number): string {
|
||||
const entry = entries[index];
|
||||
// 用户消息前面多留白
|
||||
// 用户消息前后大留白 — Claude.ai 式宽松间距
|
||||
if (entry?.type === "user_message") {
|
||||
return "pt-6 pb-2";
|
||||
return "pt-10 pb-3";
|
||||
}
|
||||
// 助手消息后面多留白(除非紧跟工具调用)
|
||||
// 助手消息 — 工具调用紧贴,否则多留白
|
||||
if (entry?.type === "assistant_message") {
|
||||
const next = entries[index + 1];
|
||||
if (next?.type === "tool_call") {
|
||||
return "pt-2 pb-1";
|
||||
return "pt-3 pb-1";
|
||||
}
|
||||
return "pt-2 pb-4";
|
||||
return "pt-3 pb-8";
|
||||
}
|
||||
return "py-1";
|
||||
// Plan 条目
|
||||
if (entry?.type === "plan") {
|
||||
return "pt-3 pb-3";
|
||||
}
|
||||
return "py-2";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -126,6 +131,8 @@ function EntryRenderer({
|
||||
onPermissionRespond={onPermissionRespond}
|
||||
/>
|
||||
);
|
||||
case "plan":
|
||||
return <PlanDisplay entry={entry as PlanDisplayEntry} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import { useMemo, useRef, useEffect } from "react";
|
||||
import {
|
||||
Command,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "../ui/command";
|
||||
import { useMemo, useRef, useEffect, useState } from "react";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import type { AvailableCommand } from "../../src/acp/types";
|
||||
|
||||
@@ -23,20 +16,11 @@ interface CommandMenuProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy match — checks if all query chars appear in order in the text.
|
||||
* Same algorithm as ModelSelectorPicker.
|
||||
* Prefix match — checks if the text starts with the query.
|
||||
*/
|
||||
function fuzzyMatch(query: string, text: string): boolean {
|
||||
function prefixMatch(query: string, text: string): boolean {
|
||||
if (!query) return true;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const lowerText = text.toLowerCase();
|
||||
let queryIdx = 0;
|
||||
for (let i = 0; i < lowerText.length && queryIdx < lowerQuery.length; i++) {
|
||||
if (lowerText[i] === lowerQuery[queryIdx]) {
|
||||
queryIdx++;
|
||||
}
|
||||
}
|
||||
return queryIdx === lowerQuery.length;
|
||||
return text.toLowerCase().startsWith(query.toLowerCase());
|
||||
}
|
||||
|
||||
export function CommandMenu({
|
||||
@@ -47,6 +31,20 @@ export function CommandMenu({
|
||||
className,
|
||||
}: CommandMenuProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
// Filter commands by current input
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return commands;
|
||||
return commands.filter(
|
||||
(cmd) => prefixMatch(filter, cmd.name),
|
||||
);
|
||||
}, [commands, filter]);
|
||||
|
||||
// Reset active index when filter changes
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [filter]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
@@ -59,13 +57,35 @@ export function CommandMenu({
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
// Filter commands by current input
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return commands;
|
||||
return commands.filter(
|
||||
(cmd) => fuzzyMatch(filter, cmd.name) || fuzzyMatch(filter, cmd.description),
|
||||
);
|
||||
}, [commands, filter]);
|
||||
// Handle keyboard navigation (ArrowUp/ArrowDown/Enter) via document-level listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (filtered.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev + 1) % filtered.length);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
|
||||
} else if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const cmd = filtered[activeIndex];
|
||||
if (cmd) onSelect(cmd);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown, true); // capture phase
|
||||
return () => document.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [filtered, activeIndex, onSelect]);
|
||||
|
||||
// Scroll active item into view
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const active = container.querySelector("[data-active='true']");
|
||||
active?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -75,39 +95,43 @@ export function CommandMenu({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList className="max-h-[320px]">
|
||||
<CommandEmpty className="text-xs text-text-muted font-display py-3">
|
||||
<div className="max-h-[320px] overflow-y-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-xs text-text-muted font-display py-3 text-center">
|
||||
没有匹配的命令
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filtered.map((cmd) => (
|
||||
<CommandItem
|
||||
key={cmd.name}
|
||||
value={cmd.name}
|
||||
onSelect={() => onSelect(cmd)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 cursor-pointer",
|
||||
"rounded-lg mx-1",
|
||||
"data-[selected=true]:bg-brand/8 data-[selected=true]:text-text-primary",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-display font-medium text-brand">
|
||||
/{cmd.name}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((cmd, index) => (
|
||||
<button
|
||||
key={cmd.name}
|
||||
type="button"
|
||||
data-active={index === activeIndex}
|
||||
onClick={() => onSelect(cmd)}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 cursor-pointer rounded-lg mx-1 text-left",
|
||||
"transition-colors",
|
||||
index === activeIndex
|
||||
? "bg-brand/10 text-text-primary"
|
||||
: "text-text-secondary hover:bg-surface-1/50",
|
||||
)}
|
||||
style={{ width: "calc(100% - 8px)" }}
|
||||
>
|
||||
<span className="text-sm font-display font-medium text-brand">
|
||||
/{cmd.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted truncate flex-1">
|
||||
{cmd.description}
|
||||
</span>
|
||||
{cmd.input?.hint && (
|
||||
<span className="text-[10px] text-text-muted italic">
|
||||
{cmd.input.hint}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted truncate flex-1">
|
||||
{cmd.description}
|
||||
</span>
|
||||
{cmd.input?.hint && (
|
||||
<span className="text-[10px] text-text-muted italic">
|
||||
{cmd.input.hint}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import type { UserMessageEntry, AssistantMessageEntry, UserMessageImage } from "../../src/lib/types";
|
||||
import { cn, esc } from "../../src/lib/utils";
|
||||
import { MessageResponse } from "../ai-elements/message";
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from "../ai-elements/reasoning";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
// 用户消息折叠最大高度(px)
|
||||
const COLLAPSED_MAX_HEIGHT = 200;
|
||||
|
||||
// =============================================================================
|
||||
// 用户消息 — 右对齐,深色反转背景,无气泡边框
|
||||
// Anthropic: right-aligned, inverted dark bg, rounded-xl with bottom-right notch
|
||||
// 用户消息 — 右对齐,品牌色淡底,可折叠
|
||||
// =============================================================================
|
||||
|
||||
interface UserBubbleProps {
|
||||
@@ -13,9 +17,23 @@ interface UserBubbleProps {
|
||||
}
|
||||
|
||||
export function UserBubble({ entry }: UserBubbleProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const checkOverflow = useCallback(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollHeight > COLLAPSED_MAX_HEIGHT + 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkOverflow();
|
||||
}, [checkOverflow, entry.content]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] sm:max-w-[75%]">
|
||||
<div className="max-w-[85%] sm:max-w-[70%]">
|
||||
{/* 图片附件 */}
|
||||
{entry.images && entry.images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2 justify-end">
|
||||
@@ -24,10 +42,32 @@ export function UserBubble({ entry }: UserBubbleProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 文本内容 */}
|
||||
{/* 文本内容 — 品牌色淡底 + 折叠 */}
|
||||
{entry.content && (
|
||||
<div className="rounded-2xl rounded-br-md bg-bg-inverted px-4 py-2.5 text-sm text-text-inverted whitespace-pre-wrap font-display leading-relaxed">
|
||||
{esc(entry.content)}
|
||||
<div className="relative rounded-2xl rounded-br-md bg-user-bubble border border-user-bubble-border overflow-hidden">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
"px-5 py-3 text-sm text-white whitespace-pre-wrap font-display leading-relaxed",
|
||||
!expanded && overflowing && `max-h-[${COLLAPSED_MAX_HEIGHT}px]`,
|
||||
)}
|
||||
style={!expanded && overflowing ? { maxHeight: `${COLLAPSED_MAX_HEIGHT}px` } : undefined}
|
||||
>
|
||||
{esc(entry.content)}
|
||||
</div>
|
||||
{/* 折叠渐变遮罩 + 展开按钮 */}
|
||||
{!expanded && overflowing && (
|
||||
<div className="absolute bottom-0 inset-x-0 flex flex-col items-center pt-8 bg-gradient-to-t from-user-bubble via-user-bubble/80 to-transparent">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="flex items-center gap-1 px-3 py-1 rounded-full text-xs font-display font-medium text-white/90 hover:bg-white/15 transition-colors"
|
||||
>
|
||||
<span>展开</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -37,7 +77,6 @@ export function UserBubble({ entry }: UserBubbleProps) {
|
||||
|
||||
// =============================================================================
|
||||
// 助手消息 — 左对齐,无背景卡片,编辑式排版
|
||||
// Anthropic: avatar + plain text, no bubble/card wrapper, serif body font
|
||||
// =============================================================================
|
||||
|
||||
interface AssistantBubbleProps {
|
||||
@@ -47,17 +86,17 @@ interface AssistantBubbleProps {
|
||||
|
||||
export function AssistantBubble({ entry, isStreaming }: AssistantBubbleProps) {
|
||||
return (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="flex gap-4 items-start">
|
||||
{/* Orange triangle avatar */}
|
||||
<div className="w-7 h-7 rounded-lg bg-brand/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M7 2L12 12H2L7 2Z" fill="var(--color-brand)" opacity=".85" />
|
||||
<div className="w-8 h-8 rounded-lg bg-brand/8 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="var(--color-brand)" fillRule="nonzero" />
|
||||
</svg>
|
||||
</div>
|
||||
{/* 内容 — 无卡片背景,直接排版 */}
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Sender label */}
|
||||
<span className="text-sm font-medium text-text-primary font-display">Claude</span>
|
||||
<span className="text-sm font-semibold text-text-primary font-display">Claude</span>
|
||||
{entry.chunks.map((chunk, i) => {
|
||||
if (chunk.type === "thought") {
|
||||
const isLastChunk = i === entry.chunks.length - 1;
|
||||
@@ -66,7 +105,7 @@ export function AssistantBubble({ entry, isStreaming }: AssistantBubbleProps) {
|
||||
<Reasoning key={i} isStreaming={isThoughtStreaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<div className="text-sm text-text-secondary">
|
||||
<div className="text-sm text-text-secondary leading-relaxed">
|
||||
{chunk.text}
|
||||
</div>
|
||||
</ReasoningContent>
|
||||
@@ -75,7 +114,7 @@ export function AssistantBubble({ entry, isStreaming }: AssistantBubbleProps) {
|
||||
}
|
||||
// 普通消息块 — 直接输出,无包裹卡片
|
||||
return (
|
||||
<div key={i} className="message-content text-text-primary leading-loose">
|
||||
<div key={i} className="message-content text-text-primary leading-[1.75]">
|
||||
<MessageResponse>{chunk.text}</MessageResponse>
|
||||
</div>
|
||||
);
|
||||
@@ -96,7 +135,6 @@ function ImageThumbnail({ image }: { image: UserMessageImage }) {
|
||||
type="button"
|
||||
className="rounded-lg overflow-hidden border border-border hover:border-brand/40 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
// 简单的点击放大 — 在新标签页打开图片
|
||||
const w = window.open("");
|
||||
if (w) {
|
||||
w.document.write(`<img src="${dataUrl}" style="max-width:100%;max-height:100%" />`);
|
||||
@@ -105,7 +143,7 @@ function ImageThumbnail({ image }: { image: UserMessageImage }) {
|
||||
>
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt="用户上传的图片"
|
||||
alt="Uploaded image"
|
||||
className="h-20 w-20 object-cover"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -41,7 +41,7 @@ interface PermissionCardProps {
|
||||
|
||||
function PermissionCard({ request, onRespond }: PermissionCardProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-warning-border/30 border-l-3 border-l-warning-border bg-warning-bg/50 px-4 py-3">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-warning-border/30 bg-warning-bg/50 px-4 py-3">
|
||||
<ShieldAlert className="h-5 w-5 text-warning-text flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-warning-text">
|
||||
|
||||
143
packages/remote-control-server/web/components/chat/PlanView.tsx
Normal file
143
packages/remote-control-server/web/components/chat/PlanView.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState } from "react";
|
||||
import type { PlanDisplayEntry } from "../../src/lib/types";
|
||||
import type { PlanEntry, PlanEntryPriority, PlanEntryStatus } from "../../src/acp/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { CheckCircle2, Loader2, Circle } from "lucide-react";
|
||||
|
||||
// =============================================================================
|
||||
// Plan 展示组件 — 执行计划可视化
|
||||
// =============================================================================
|
||||
|
||||
interface PlanDisplayProps {
|
||||
entry: PlanDisplayEntry;
|
||||
}
|
||||
|
||||
export function PlanDisplay({ entry }: PlanDisplayProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { entries } = entry;
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const completed = entries.filter((e) => e.status === "completed").length;
|
||||
const total = entries.length;
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<div className="rounded-xl border border-border bg-brand/5 overflow-hidden">
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm hover:bg-surface-1/50 transition-colors"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className={cn("transition-transform text-text-muted flex-shrink-0", collapsed && "rotate-90")}
|
||||
>
|
||||
<path d="M4 2L8 6L4 10" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
|
||||
<span className="text-xs font-display font-medium text-text-secondary">
|
||||
执行计划
|
||||
</span>
|
||||
|
||||
<span className="text-[10px] text-text-muted font-mono">
|
||||
{completed}/{total}
|
||||
</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1 rounded-full bg-surface-1 overflow-hidden ml-1 mr-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand/70 transition-all duration-500"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-text-muted font-mono">
|
||||
{percentage}%
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Entry list */}
|
||||
{!collapsed && (
|
||||
<div className={cn(
|
||||
"border-t border-border px-3 py-1.5 space-y-0.5",
|
||||
total > 5 && "max-h-64 overflow-y-auto",
|
||||
)}>
|
||||
{entries.map((planEntry, i) => (
|
||||
<PlanEntryRow key={i} entry={planEntry} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 单条 Plan 条目
|
||||
// =============================================================================
|
||||
|
||||
function PlanEntryRow({ entry }: { entry: PlanEntry }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 py-1.5 px-1">
|
||||
<span className="flex-shrink-0 mt-0.5">
|
||||
<StatusIcon status={entry.status} />
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-xs leading-relaxed flex-1",
|
||||
entry.status === "completed" ? "text-text-muted line-through" : "text-text-secondary",
|
||||
entry.status === "in_progress" && "text-text-primary font-medium",
|
||||
)}>
|
||||
{entry.content}
|
||||
</span>
|
||||
<PriorityBadge priority={entry.priority} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 状态图标
|
||||
// =============================================================================
|
||||
|
||||
function StatusIcon({ status }: { status: PlanEntryStatus }) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircle2 className="h-3.5 w-3.5 text-status-active" />;
|
||||
case "in_progress":
|
||||
return <Loader2 className="h-3.5 w-3.5 text-brand animate-spin" style={{ animationDuration: "2s" }} />;
|
||||
case "pending":
|
||||
return <Circle className="h-3.5 w-3.5 text-text-muted" />;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 优先级标签
|
||||
// =============================================================================
|
||||
|
||||
function PriorityBadge({ priority }: { priority: PlanEntryPriority }) {
|
||||
const styles: Record<PlanEntryPriority, string> = {
|
||||
high: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
medium: "bg-brand/10 text-brand dark:bg-brand/20",
|
||||
low: "bg-surface-1 text-text-muted",
|
||||
};
|
||||
|
||||
const labels: Record<PlanEntryPriority, string> = {
|
||||
high: "高",
|
||||
medium: "中",
|
||||
low: "低",
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
"text-[9px] font-display rounded-full px-1.5 py-0.5 flex-shrink-0 leading-none",
|
||||
styles[priority],
|
||||
)}>
|
||||
{labels[priority]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -80,10 +80,10 @@ export function SessionSidebar({
|
||||
type="button"
|
||||
onClick={() => onSelect?.(session.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors border-l-2",
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
|
||||
session.id === activeId
|
||||
? "bg-brand/10 text-text-primary border-l-brand"
|
||||
: "text-text-secondary hover:bg-surface-1/50 hover:text-text-primary border-l-transparent",
|
||||
? "bg-brand/10 text-text-primary"
|
||||
: "text-text-secondary hover:bg-surface-1/50 hover:text-text-primary",
|
||||
)}
|
||||
title={session.title || session.id}
|
||||
>
|
||||
|
||||
@@ -17,12 +17,13 @@ export function ToolCallGroup({ entries, onPermissionRespond }: ToolCallGroupPro
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
// 单个工具调用
|
||||
// 单个工具调用 — 默认折叠,不展开内容详情
|
||||
if (entries.length === 1) {
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<SingleToolCard
|
||||
tool={entries[0].toolCall}
|
||||
compact
|
||||
onPermissionRespond={onPermissionRespond}
|
||||
/>
|
||||
</div>
|
||||
@@ -34,7 +35,7 @@ export function ToolCallGroup({ entries, onPermissionRespond }: ToolCallGroupPro
|
||||
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<div className="rounded-lg border border-border border-l-3 border-l-brand/50 bg-surface-2/50 overflow-hidden">
|
||||
<div className="rounded-lg border border-border bg-surface-2/50 overflow-hidden">
|
||||
{/* 折叠头 */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -108,8 +109,9 @@ function SingleToolCard({ tool, compact, onPermissionRespond }: SingleToolCardPr
|
||||
return (
|
||||
<div className={cn("px-3 py-2", compact && "py-1.5")}>
|
||||
{/* 标题行 — 单行紧凑 */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer group"
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 text-left group"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{statusIcon}
|
||||
@@ -119,7 +121,7 @@ function SingleToolCard({ tool, compact, onPermissionRespond }: SingleToolCardPr
|
||||
{tool.status === "running" && (
|
||||
<span className="text-[10px] text-status-running animate-pulse">running</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 权限请求按钮 */}
|
||||
{tool.status === "waiting_for_confirmation" && tool.permissionRequest && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { ChatView } from "./ChatView";
|
||||
export { UserBubble, AssistantBubble } from "./MessageBubble";
|
||||
export { ToolCallGroup } from "./ToolCallGroup";
|
||||
export { PlanDisplay } from "./PlanView";
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export { PermissionPanel } from "./PermissionPanel";
|
||||
export { SessionSidebar } from "./SessionSidebar";
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Remote Control — Claude Code</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, lazy, Suspense } from "react";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { SessionDetail } from "./pages/SessionDetail";
|
||||
import { IdentityPanel } from "./components/IdentityPanel";
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import { getUuid, setUuid, apiBind } from "./api/client";
|
||||
import { ACPDirectView } from "./components/ACPDirectView";
|
||||
|
||||
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
|
||||
const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail })));
|
||||
|
||||
export default function App() {
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [identityOpen, setIdentityOpen] = useState(false);
|
||||
const [acpDirect, setAcpDirect] = useState<{ url: string; token: string } | null>(null);
|
||||
|
||||
// Simple hash-based router
|
||||
const parseRoute = useCallback(() => {
|
||||
@@ -27,6 +30,28 @@ export default function App() {
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
|
||||
// Check for ACP direct connection (?acp=1)
|
||||
const acpParam = params.get("acp");
|
||||
if (acpParam === "1") {
|
||||
const stored = sessionStorage.getItem("acp_connection");
|
||||
if (stored) {
|
||||
try {
|
||||
const acpData = JSON.parse(stored);
|
||||
if (acpData.url && acpData.token) {
|
||||
setAcpDirect({ url: acpData.url, token: acpData.token });
|
||||
sessionStorage.removeItem("acp_connection");
|
||||
// Clean URL
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("acp");
|
||||
window.history.replaceState(null, "", url);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem("acp_connection");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CLI session bind (?sid=xxx) — bind session to current UUID
|
||||
const sid = params.get("sid");
|
||||
if (sid) {
|
||||
@@ -64,20 +89,29 @@ export default function App() {
|
||||
const navigateToDashboard = useCallback(() => {
|
||||
window.history.pushState(null, "", "/code/");
|
||||
setCurrentSessionId(null);
|
||||
setAcpDirect(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider defaultTheme="light">
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<div className="flex h-screen flex-col bg-surface-0 text-text-primary">
|
||||
<Navbar onIdentityClick={() => setIdentityOpen(true)} />
|
||||
<Navbar
|
||||
onIdentityClick={() => setIdentityOpen(true)}
|
||||
sessionTitle={currentSessionId || (acpDirect ? "ACP" : undefined)}
|
||||
onBack={(currentSessionId || acpDirect) ? navigateToDashboard : undefined}
|
||||
/>
|
||||
|
||||
{currentSessionId ? (
|
||||
<SessionDetail key={currentSessionId} sessionId={currentSessionId} />
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Dashboard onNavigateSession={navigateToSession} />
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<div className="flex flex-1 items-center justify-center text-text-muted">Loading...</div>}>
|
||||
{acpDirect ? (
|
||||
<ACPDirectView url={acpDirect.url} token={acpDirect.token} onBack={navigateToDashboard} />
|
||||
) : currentSessionId ? (
|
||||
<SessionDetail key={currentSessionId} sessionId={currentSessionId} />
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Dashboard onNavigateSession={navigateToSession} />
|
||||
</div>
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
<IdentityPanel open={identityOpen} onClose={() => setIdentityOpen(false)} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
// In-memory localStorage mock
|
||||
let store: Record<string, string> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
store = {};
|
||||
(globalThis as any).localStorage = {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = v; },
|
||||
removeItem: (k: string) => { delete store[k]; },
|
||||
clear: () => { store = {}; },
|
||||
get length() { return Object.keys(store).length; },
|
||||
key: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fetch
|
||||
const fetchMock = {
|
||||
lastUrl: "",
|
||||
lastOpts: {} as RequestInit,
|
||||
response: { ok: true, status: 200, statusText: "OK" },
|
||||
responseData: {} as any,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.lastUrl = "";
|
||||
fetchMock.lastOpts = {};
|
||||
fetchMock.response = { ok: true, status: 200, statusText: "OK" };
|
||||
fetchMock.responseData = {};
|
||||
});
|
||||
|
||||
(globalThis as any).fetch = async (url: string, opts: RequestInit) => {
|
||||
fetchMock.lastUrl = url;
|
||||
fetchMock.lastOpts = opts;
|
||||
return {
|
||||
ok: fetchMock.response.ok,
|
||||
status: fetchMock.response.status,
|
||||
statusText: fetchMock.response.statusText,
|
||||
json: async () => fetchMock.responseData,
|
||||
} as Response;
|
||||
};
|
||||
|
||||
// Mock crypto.randomUUID
|
||||
(globalThis as any).crypto = {
|
||||
randomUUID: () => "test-uuid-12345678",
|
||||
};
|
||||
|
||||
const { getUuid, setUuid } = await import("../api/client");
|
||||
|
||||
// Import api* functions - they depend on getUuid and fetch
|
||||
const client = await import("../api/client");
|
||||
|
||||
// =============================================================================
|
||||
// getUuid()
|
||||
// =============================================================================
|
||||
|
||||
describe("getUuid", () => {
|
||||
test("returns existing UUID from localStorage", () => {
|
||||
store["rcs_uuid"] = "existing-uuid";
|
||||
expect(getUuid()).toBe("existing-uuid");
|
||||
});
|
||||
|
||||
test("generates and stores new UUID when none exists", () => {
|
||||
const uuid = getUuid();
|
||||
expect(uuid).toBe("test-uuid-12345678");
|
||||
expect(store["rcs_uuid"]).toBe("test-uuid-12345678");
|
||||
});
|
||||
|
||||
test("returns same UUID on subsequent calls", () => {
|
||||
const a = getUuid();
|
||||
const b = getUuid();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// setUuid()
|
||||
// =============================================================================
|
||||
|
||||
describe("setUuid", () => {
|
||||
test("writes UUID to localStorage", () => {
|
||||
setUuid("custom-uuid-999");
|
||||
expect(store["rcs_uuid"]).toBe("custom-uuid-999");
|
||||
});
|
||||
|
||||
test("getUuid returns the set UUID", () => {
|
||||
setUuid("my-uuid");
|
||||
expect(getUuid()).toBe("my-uuid");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// api() — tested via apiFetchSession (GET) and apiBind (POST)
|
||||
// =============================================================================
|
||||
|
||||
describe("api functions", () => {
|
||||
test("GET request appends uuid to URL", async () => {
|
||||
store["rcs_uuid"] = "test-uuid";
|
||||
fetchMock.responseData = [];
|
||||
await client.apiFetchSessions();
|
||||
expect(fetchMock.lastUrl).toContain("uuid=test-uuid");
|
||||
expect(fetchMock.lastOpts.method).toBe("GET");
|
||||
});
|
||||
|
||||
test("GET request uses ? for URL without existing query params", async () => {
|
||||
store["rcs_uuid"] = "test-uuid";
|
||||
fetchMock.responseData = [];
|
||||
await client.apiFetchSessions();
|
||||
expect(fetchMock.lastUrl).toContain("?uuid=");
|
||||
});
|
||||
|
||||
test("GET request uses & for URL with existing query params", async () => {
|
||||
store["rcs_uuid"] = "test-uuid";
|
||||
fetchMock.responseData = [];
|
||||
await client.apiFetchAllSessions();
|
||||
// apiFetchAllSessions calls GET /web/sessions/all
|
||||
expect(fetchMock.lastUrl).toContain("?uuid=");
|
||||
});
|
||||
|
||||
test("POST request includes JSON body", async () => {
|
||||
store["rcs_uuid"] = "test-uuid";
|
||||
fetchMock.responseData = {};
|
||||
await client.apiBind("sess-1");
|
||||
expect(fetchMock.lastOpts.method).toBe("POST");
|
||||
expect(fetchMock.lastOpts.body).toBe(JSON.stringify({ sessionId: "sess-1" }));
|
||||
expect(fetchMock.lastOpts.headers).toEqual({ "Content-Type": "application/json" });
|
||||
});
|
||||
|
||||
test("throws error on non-ok response", async () => {
|
||||
store["rcs_uuid"] = "test-uuid";
|
||||
fetchMock.response = { ok: false, status: 401, statusText: "Unauthorized" };
|
||||
fetchMock.responseData = { error: { type: "auth", message: "Invalid UUID" } };
|
||||
await expect(client.apiFetchSessions()).rejects.toThrow("Invalid UUID");
|
||||
});
|
||||
|
||||
test("throws with statusText when error message is missing", async () => {
|
||||
store["rcs_uuid"] = "test-uuid";
|
||||
fetchMock.response = { ok: false, status: 500, statusText: "Internal Server Error" };
|
||||
fetchMock.responseData = {};
|
||||
await expect(client.apiFetchSessions()).rejects.toThrow("Internal Server Error");
|
||||
});
|
||||
});
|
||||
221
packages/remote-control-server/web/src/__tests__/utils.test.ts
Normal file
221
packages/remote-control-server/web/src/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
|
||||
const {
|
||||
formatTime,
|
||||
statusClass,
|
||||
isClosedSessionStatus,
|
||||
truncate,
|
||||
generateMessageUuid,
|
||||
extractEventText,
|
||||
isConversationClearedStatus,
|
||||
} = await import("../lib/utils");
|
||||
|
||||
// =============================================================================
|
||||
// formatTime()
|
||||
// =============================================================================
|
||||
|
||||
describe("formatTime", () => {
|
||||
test("returns empty string for null", () => {
|
||||
expect(formatTime(null)).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string for undefined", () => {
|
||||
expect(formatTime(undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string for 0", () => {
|
||||
expect(formatTime(0)).toBe("");
|
||||
});
|
||||
|
||||
test("formats valid unix timestamp", () => {
|
||||
const result = formatTime(1700000000);
|
||||
expect(result).toContain("2023");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// statusClass()
|
||||
// =============================================================================
|
||||
|
||||
describe("statusClass", () => {
|
||||
test("maps known statuses correctly", () => {
|
||||
expect(statusClass("active")).toBe("active");
|
||||
expect(statusClass("running")).toBe("running");
|
||||
expect(statusClass("idle")).toBe("idle");
|
||||
expect(statusClass("inactive")).toBe("inactive");
|
||||
expect(statusClass("requires_action")).toBe("requires_action");
|
||||
expect(statusClass("archived")).toBe("archived");
|
||||
expect(statusClass("error")).toBe("error");
|
||||
});
|
||||
|
||||
test("returns default for unknown status", () => {
|
||||
expect(statusClass("unknown")).toBe("default");
|
||||
});
|
||||
|
||||
test("returns default for null", () => {
|
||||
expect(statusClass(null)).toBe("default");
|
||||
});
|
||||
|
||||
test("returns default for undefined", () => {
|
||||
expect(statusClass(undefined)).toBe("default");
|
||||
});
|
||||
|
||||
test("returns default for empty string", () => {
|
||||
expect(statusClass("")).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// isClosedSessionStatus()
|
||||
// =============================================================================
|
||||
|
||||
describe("isClosedSessionStatus", () => {
|
||||
test("returns true for archived", () => {
|
||||
expect(isClosedSessionStatus("archived")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for inactive", () => {
|
||||
expect(isClosedSessionStatus("inactive")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for active", () => {
|
||||
expect(isClosedSessionStatus("active")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for null", () => {
|
||||
expect(isClosedSessionStatus(null)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
expect(isClosedSessionStatus(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// truncate()
|
||||
// =============================================================================
|
||||
|
||||
describe("truncate", () => {
|
||||
test("returns empty string for null", () => {
|
||||
expect(truncate(null, 10)).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string for undefined", () => {
|
||||
expect(truncate(undefined, 10)).toBe("");
|
||||
});
|
||||
|
||||
test("returns original string when shorter than max", () => {
|
||||
expect(truncate("hello", 10)).toBe("hello");
|
||||
});
|
||||
|
||||
test("returns original string when exactly max length", () => {
|
||||
expect(truncate("12345", 5)).toBe("12345");
|
||||
});
|
||||
|
||||
test("truncates and appends ... when longer than max", () => {
|
||||
expect(truncate("hello world", 5)).toBe("hello...");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// generateMessageUuid()
|
||||
// =============================================================================
|
||||
|
||||
describe("generateMessageUuid", () => {
|
||||
test("returns a non-empty string", () => {
|
||||
const uuid = generateMessageUuid();
|
||||
expect(typeof uuid).toBe("string");
|
||||
expect(uuid.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// extractEventText()
|
||||
// =============================================================================
|
||||
|
||||
describe("extractEventText", () => {
|
||||
test("returns empty string for null", () => {
|
||||
expect(extractEventText(null)).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string for undefined", () => {
|
||||
expect(extractEventText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string for non-object", () => {
|
||||
expect(extractEventText("string" as any)).toBe("");
|
||||
});
|
||||
|
||||
test("extracts payload.content string", () => {
|
||||
expect(extractEventText({ content: "hello" })).toBe("hello");
|
||||
});
|
||||
|
||||
test("extracts from message.content text blocks array", () => {
|
||||
const payload = {
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "line 1" },
|
||||
{ type: "text", text: "line 2" },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(extractEventText(payload)).toBe("line 1\nline 2");
|
||||
});
|
||||
|
||||
test("ignores non-text blocks", () => {
|
||||
const payload = {
|
||||
message: {
|
||||
content: [
|
||||
{ type: "image", data: "base64..." },
|
||||
{ type: "text", text: "only text" },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(extractEventText(payload)).toBe("only text");
|
||||
});
|
||||
|
||||
test("returns empty string when message.content has no text blocks", () => {
|
||||
const payload = {
|
||||
message: { content: [{ type: "image", data: "base64" }] },
|
||||
};
|
||||
expect(extractEventText(payload)).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string for empty object", () => {
|
||||
expect(extractEventText({})).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// isConversationClearedStatus()
|
||||
// =============================================================================
|
||||
|
||||
describe("isConversationClearedStatus", () => {
|
||||
test("returns true when payload.status is conversation_cleared", () => {
|
||||
expect(isConversationClearedStatus({ status: "conversation_cleared" })).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when payload.raw.status is conversation_cleared", () => {
|
||||
expect(isConversationClearedStatus({ raw: { status: "conversation_cleared" } })).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for null", () => {
|
||||
expect(isConversationClearedStatus(null)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
expect(isConversationClearedStatus(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for other status", () => {
|
||||
expect(isConversationClearedStatus({ status: "active" })).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when raw has different status", () => {
|
||||
expect(isConversationClearedStatus({ raw: { status: "running" } })).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty object", () => {
|
||||
expect(isConversationClearedStatus({})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -568,10 +568,10 @@ export class ACPClient {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
async createSession(cwd?: string): Promise<void> {
|
||||
async createSession(cwd?: string, permissionMode?: string): Promise<void> {
|
||||
// Use provided cwd, or fall back to settings.cwd
|
||||
const sessionCwd = cwd ?? this.settings.cwd;
|
||||
this.send({ type: "new_session", payload: { cwd: sessionCwd } });
|
||||
this.send({ type: "new_session", payload: { cwd: sessionCwd, permissionMode } });
|
||||
}
|
||||
|
||||
// Reference: Zed's MessageEditor.contents() builds Vec<acp::ContentBlock>
|
||||
|
||||
@@ -88,7 +88,7 @@ export type BrowserToolResult =
|
||||
export type ProxyMessage =
|
||||
| { type: "connect" }
|
||||
| { type: "disconnect" }
|
||||
| { type: "new_session"; payload?: { cwd?: string } }
|
||||
| { type: "new_session"; payload?: { cwd?: string; permissionMode?: string } }
|
||||
| { type: "prompt"; payload: { content: ContentBlock[] } } // Changed from { text: string } to match Zed
|
||||
| { type: "cancel" }
|
||||
| { type: "permission_response"; payload: PermissionResponsePayload }
|
||||
@@ -295,8 +295,20 @@ export interface AgentThoughtChunkUpdate {
|
||||
content: ContentBlock;
|
||||
}
|
||||
|
||||
export type PlanEntryPriority = "high" | "medium" | "low";
|
||||
export type PlanEntryStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
export interface PlanEntry {
|
||||
_meta?: Record<string, unknown> | null;
|
||||
content: string;
|
||||
priority: PlanEntryPriority;
|
||||
status: PlanEntryStatus;
|
||||
}
|
||||
|
||||
export interface PlanUpdate {
|
||||
sessionUpdate: "plan";
|
||||
_meta?: Record<string, unknown> | null;
|
||||
entries: PlanEntry[];
|
||||
}
|
||||
|
||||
export interface UserMessageChunkUpdate {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ACPClient, DisconnectRequestedError } from "../acp/client";
|
||||
import type { ConnectionState } from "../acp/types";
|
||||
import { ACPMain } from "../../components/ACPMain";
|
||||
|
||||
interface ACPDirectViewProps {
|
||||
url: string;
|
||||
token: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ACPDirectView({ url, token, onBack }: ACPDirectViewProps) {
|
||||
const [client, setClient] = useState<ACPClient | null>(null);
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const clientRef = useRef<ACPClient | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const acpClient = new ACPClient({ proxyUrl: url, token });
|
||||
|
||||
acpClient.setConnectionStateHandler((state, err) => {
|
||||
setConnectionState(state);
|
||||
setError(err || null);
|
||||
});
|
||||
|
||||
clientRef.current = acpClient;
|
||||
setClient(acpClient);
|
||||
|
||||
acpClient.connect().catch((e) => {
|
||||
if (e instanceof DisconnectRequestedError) return;
|
||||
setError((e as Error).message);
|
||||
setConnectionState("error");
|
||||
});
|
||||
|
||||
return () => {
|
||||
acpClient.disconnect();
|
||||
clientRef.current = null;
|
||||
setClient(null);
|
||||
setConnectionState("disconnected");
|
||||
};
|
||||
}, [url, token]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{error && connectionState === "error" && (
|
||||
<div className="px-4 py-2 bg-status-error/10 text-status-error text-sm border-b">
|
||||
{error}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="ml-3 underline hover:no-underline"
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionState === "connecting" && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-brand border-t-transparent rounded-full mx-auto mb-3" />
|
||||
<p className="text-text-muted text-sm">Connecting to ACP agent...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionState === "error" && !client && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="font-medium mb-1">Connection Failed</p>
|
||||
<p className="text-text-muted text-sm mb-3">{error}</p>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brand-light transition-colors"
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{client && connectionState === "connected" && (
|
||||
<ACPMain client={client} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { cn, isClosedSessionStatus } from "../lib/utils";
|
||||
import { Square, SendHorizonal } from "lucide-react";
|
||||
|
||||
interface ControlBarProps {
|
||||
sessionId: string;
|
||||
@@ -70,13 +71,9 @@ export function ControlBar({
|
||||
title={closed ? "Session is closed" : working ? "Stop" : "Send"}
|
||||
>
|
||||
{working ? (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<rect x="3" y="3" width="12" height="12" rx="2" fill="currentColor" />
|
||||
</svg>
|
||||
<Square className="h-4.5 w-4.5 fill-current" />
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M3 10L17 3L10 17L9 11L3 10Z" fill="currentColor" />
|
||||
</svg>
|
||||
<SendHorizonal className="h-5 w-5 fill-current" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -21,13 +21,15 @@ export function EnvironmentList({ environments, onSelectEnvironment }: Environme
|
||||
{environments.map((env) => {
|
||||
const isAcp = env.worker_type === "acp";
|
||||
const typeLabel = isAcp ? "ACP Agent" : "Claude Code";
|
||||
const typeColor = isAcp ? "bg-purple-100 text-purple-700" : "bg-blue-100 text-blue-700";
|
||||
const typeColor = isAcp ? "bg-brand/15 text-brand" : "bg-status-running/15 text-status-running";
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={env.id}
|
||||
type="button"
|
||||
onClick={() => onSelectEnvironment?.(env)}
|
||||
className={`flex items-center justify-between rounded-xl border border-border bg-surface-1 px-4 py-3 transition-colors hover:border-border-light ${onSelectEnvironment ? "cursor-pointer" : ""}`}
|
||||
disabled={isAcp}
|
||||
className={`flex w-full items-center justify-between rounded-xl border border-border bg-surface-1 px-4 py-3 text-left transition-colors ${isAcp ? "cursor-default opacity-80" : "hover:border-border-light cursor-pointer"}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
@@ -48,7 +50,7 @@ export function EnvironmentList({ environments, onSelectEnvironment }: Environme
|
||||
{env.branch || ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,14 @@ import QRCode from "qrcode";
|
||||
import QrScanner from "qr-scanner";
|
||||
import { getUuid, setUuid } from "../api/client";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Scan } from "lucide-react";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
|
||||
interface IdentityPanelProps {
|
||||
open: boolean;
|
||||
@@ -16,16 +24,28 @@ export function IdentityPanel({ open, onClose }: IdentityPanelProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const scannerRef = useRef<QrScanner | null>(null);
|
||||
const uuid = getUuid();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const qrColors = resolvedTheme === "dark"
|
||||
? { dark: "#ECE9E0", light: "#1C1B18" }
|
||||
: { dark: "#141413", light: "#FDFCF8" };
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canvasRef.current) return;
|
||||
const qrUrl = `${window.location.origin}/code?uuid=${encodeURIComponent(uuid)}`;
|
||||
QRCode.toCanvas(canvasRef.current, qrUrl, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: { dark: "#f0f0f2", light: "#141416" },
|
||||
if (!open) return;
|
||||
// Defer one frame so Radix Dialog Portal has finished mounting the canvas
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (!canvasRef.current) return;
|
||||
const qrUrl = `${window.location.origin}/code?uuid=${encodeURIComponent(uuid)}`;
|
||||
QRCode.toCanvas(canvasRef.current, qrUrl, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: qrColors,
|
||||
}).catch((err: unknown) => {
|
||||
console.error("QR generation failed:", err);
|
||||
});
|
||||
});
|
||||
}, [open, uuid]);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [open, uuid, resolvedTheme]);
|
||||
|
||||
// Cleanup scanner on close
|
||||
useEffect(() => {
|
||||
@@ -78,9 +98,11 @@ export function IdentityPanel({ open, onClose }: IdentityPanelProps) {
|
||||
// Try ACP format: { url, token }
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.url && parsed.token) {
|
||||
// ACP format — extract token as UUID-like identifier
|
||||
// Store ACP connection data and navigate to ACP direct connect view
|
||||
stopCamera();
|
||||
onClose();
|
||||
sessionStorage.setItem("acp_connection", JSON.stringify({ url: parsed.url, token: parsed.token }));
|
||||
window.location.href = "/code/?acp=1";
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -130,20 +152,11 @@ export function IdentityPanel({ open, onClose }: IdentityPanelProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-2xl border border-border bg-surface-1 p-6 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary">Identity</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md px-2 py-1 text-text-muted hover:bg-surface-2 hover:text-text-secondary transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-sm rounded-2xl border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display text-lg font-semibold text-text-primary">Identity</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* UUID */}
|
||||
@@ -199,9 +212,7 @@ export function IdentityPanel({ open, onClose }: IdentityPanelProps) {
|
||||
: "border-border text-text-secondary hover:bg-surface-2",
|
||||
)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M1 1H5V3H3V5H1V1ZM11 1H15V5H13V3H11V1ZM1 11H3V13H5V15H1V11ZM13 11H15V15H11V13H13V11ZM6 6H10V10H6V6Z" fill="currentColor" />
|
||||
</svg>
|
||||
<Scan className="h-4 w-4" />
|
||||
{scanning ? "Stop Camera" : "Scan with Camera"}
|
||||
</button>
|
||||
<button
|
||||
@@ -212,7 +223,7 @@ export function IdentityPanel({ open, onClose }: IdentityPanelProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,63 @@
|
||||
import { cn } from "../lib/utils";
|
||||
import { ThemeToggle } from "../../components/ui/theme-toggle";
|
||||
import { ChevronLeft, LayoutGrid, UserPlus } from "lucide-react";
|
||||
|
||||
interface NavbarProps {
|
||||
onIdentityClick: () => void;
|
||||
sessionTitle?: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function Navbar({ onIdentityClick }: NavbarProps) {
|
||||
export function Navbar({ onIdentityClick, sessionTitle, onBack }: NavbarProps) {
|
||||
return (
|
||||
<nav className="sticky top-0 z-40 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
|
||||
<a href="/code/" className="flex items-center gap-2 font-display text-lg font-semibold text-text-primary no-underline">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M10 1L12.2 7.8L19 10L12.2 12.2L10 19L7.8 12.2L1 10L7.8 7.8L10 1Z"
|
||||
fill="#D97757"
|
||||
/>
|
||||
</svg>
|
||||
Remote Control
|
||||
</a>
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="/code/"
|
||||
className="rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 hover:text-text-primary no-underline transition-colors"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
<button
|
||||
onClick={onIdentityClick}
|
||||
className="flex items-center gap-1 rounded-md px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 hover:text-text-primary transition-colors"
|
||||
title="Identity & QR"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<div className="mx-auto flex h-11 sm:h-12 max-w-5xl items-center justify-between px-3 sm:px-4">
|
||||
{sessionTitle ? (
|
||||
/* Session 页面 — 返回按钮 + agent 名 */
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1 text-sm text-text-muted hover:text-text-primary transition-colors flex-shrink-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Dashboard</span>
|
||||
</button>
|
||||
<span className="text-text-muted/40">/</span>
|
||||
<span className="text-sm font-display font-medium text-text-primary truncate">{sessionTitle}</span>
|
||||
<span className="rounded-full bg-brand/15 px-2 py-0.5 text-[10px] font-medium text-brand flex-shrink-0">ACP</span>
|
||||
</div>
|
||||
) : (
|
||||
/* Dashboard 页面 — 品牌 */
|
||||
<a href="/code/" className="flex items-center gap-2 font-display text-lg font-semibold text-text-primary no-underline">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true" className="flex-shrink-0">
|
||||
<path
|
||||
d="M6 8C7.66 8 9 6.66 9 5C9 3.34 7.66 2 6 2C4.34 2 3 3.34 3 5C3 6.66 4.34 8 6 8ZM6 10C3.99 10 0 11.01 0 13V14H12V13C12 11.01 8.01 10 6 10ZM13 8V5H11V8H8V10H11V13H13V10H16V8H13Z"
|
||||
fill="currentColor"
|
||||
d="M10 1L12.2 7.8L19 10L12.2 12.2L10 19L7.8 12.2L1 10L7.8 7.8L10 1Z"
|
||||
fill="var(--color-brand)"
|
||||
/>
|
||||
</svg>
|
||||
Identity
|
||||
<span className="hidden sm:inline">Remote Control</span>
|
||||
</a>
|
||||
)}
|
||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||
{!sessionTitle && (
|
||||
<a
|
||||
href="/code/"
|
||||
className="flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 hover:text-text-primary no-underline transition-colors"
|
||||
title="Dashboard"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Dashboard</span>
|
||||
</a>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<button
|
||||
onClick={onIdentityClick}
|
||||
className="flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 hover:text-text-primary transition-colors"
|
||||
title="Identity & QR"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Identity</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { Environment, Session } from "../types";
|
||||
import { apiCreateSession } from "../api/client";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "../../components/ui/dialog";
|
||||
|
||||
interface NewSessionDialogProps {
|
||||
open: boolean;
|
||||
@@ -23,8 +30,6 @@ export function NewSessionDialog({ open, environments, onClose, onCreated }: New
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true);
|
||||
setError("");
|
||||
@@ -42,12 +47,11 @@ export function NewSessionDialog({ open, environments, onClose, onCreated }: New
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-md rounded-2xl border border-border bg-surface-1 p-6 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="mb-4 font-display text-lg font-semibold text-text-primary">New Session</h3>
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-md rounded-2xl border-border bg-surface-1 p-6 shadow-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display text-lg font-semibold text-text-primary">New Session</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
@@ -78,24 +82,24 @@ export function NewSessionDialog({ open, environments, onClose, onCreated }: New
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-status-error">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-text-secondary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brand-light disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-text-secondary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brand-light disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { Question } from "../types";
|
||||
import { esc, cn, truncate } from "../lib/utils";
|
||||
import { TriangleAlert, Check } from "lucide-react";
|
||||
|
||||
// ============================================================
|
||||
// PermissionPromptView — simple approve/reject for tool use
|
||||
@@ -24,12 +25,10 @@ export function PermissionPromptView({
|
||||
const inputStr = typeof toolInput === "string" ? toolInput : JSON.stringify(toolInput, null, 2);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-warning-border/30 border-l-3 border-l-warning-border bg-surface-1 p-4">
|
||||
<div className="rounded-xl border border-warning-border/30 bg-surface-1 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-warning-border/15 text-warning-text">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M6 1L11 10H1L6 1Z" fill="currentColor" />
|
||||
</svg>
|
||||
<TriangleAlert className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-warning-text">Permission Request</span>
|
||||
</div>
|
||||
@@ -284,9 +283,7 @@ export function PlanPanelView({
|
||||
<div className="rounded-xl border border-brand/30 bg-surface-1 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand/15 text-brand">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 6L5 9L10 3" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
<Check className="h-3 w-3" strokeWidth={2.5} />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
{isEmpty ? "Exit plan mode?" : "Ready to code?"}
|
||||
|
||||
@@ -21,10 +21,11 @@ export function SessionList({ sessions, onSelect }: SessionListProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sorted.map((session) => (
|
||||
<div
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(session.id)}
|
||||
className="flex cursor-pointer items-center justify-between rounded-xl border border-border bg-surface-1 px-4 py-3 transition-colors hover:border-border-light hover:bg-surface-2"
|
||||
className="flex w-full items-center justify-between rounded-xl border border-border bg-surface-1 px-4 py-3 text-left transition-colors hover:border-border-light hover:bg-surface-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -32,7 +33,7 @@ export function SessionList({ sessions, onSelect }: SessionListProps) {
|
||||
{session.title || session.id}
|
||||
</span>
|
||||
{session.source === "acp" && (
|
||||
<span className="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">
|
||||
<span className="rounded-full bg-brand/15 px-2 py-0.5 text-xs font-medium text-brand">
|
||||
ACP
|
||||
</span>
|
||||
)}
|
||||
@@ -45,7 +46,7 @@ export function SessionList({ sessions, onSelect }: SessionListProps) {
|
||||
{formatTime(session.created_at || session.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
|
||||
interface TaskPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TaskPanel({ onClose }: TaskPanelProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/30" onClick={onClose}>
|
||||
<div
|
||||
className="w-80 border-l border-border bg-surface-1 p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<Dialog open={true} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="fixed inset-y-0 right-0 top-auto left-auto translate-x-0 translate-y-0 w-full sm:w-80 h-full max-w-none max-h-none rounded-none border-l border-border bg-surface-1 p-4 sm:max-w-sm"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-display font-semibold text-text-primary">Tasks</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md px-2 py-1 text-text-muted hover:bg-surface-2 hover:text-text-secondary transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display font-semibold text-text-primary">Tasks</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-sm text-text-muted">No active tasks</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,13 @@ export function useModels(client: ACPClient): UseModelsResult {
|
||||
// Handler for when model state changes (session created or disconnected)
|
||||
const handleModelStateChanged = (state: SessionModelState | null) => {
|
||||
setModelState(state);
|
||||
// Auto-restore previously selected model when a new session is created
|
||||
if (state && state.availableModels.length > 0) {
|
||||
const saved = localStorage.getItem("acp_model_id");
|
||||
if (saved && saved !== state.currentModelId && state.availableModels.some((m) => m.modelId === saved)) {
|
||||
client.setSessionModel(saved).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for when current model changes within a session
|
||||
@@ -83,6 +90,7 @@ export function useModels(client: ACPClient): UseModelsResult {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await client.setSessionModel(modelId);
|
||||
localStorage.setItem("acp_model_id", modelId);
|
||||
// The model_changed event will update the state
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Theme — Anthropic-inspired warm design tokens
|
||||
Theme — Refined stone palette (warm gray, not beige)
|
||||
Hue shifted from 85 (yellow-cream) to 65 (warm stone)
|
||||
Brand orange pops harder against cleaner neutrals
|
||||
============================================================ */
|
||||
@theme {
|
||||
/* Fonts — Lora (serif body) + Poppins (sans-serif UI) + JetBrains Mono */
|
||||
@@ -14,63 +16,67 @@
|
||||
--font-display: "Poppins", sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
/* Brand — signature orange */
|
||||
--color-brand: #D97757;
|
||||
--color-brand-light: #C96A4D;
|
||||
--color-brand-subtle: rgba(217, 119, 87, 0.1);
|
||||
/* Brand — signature orange (unchanged) */
|
||||
--color-brand: #C96442;
|
||||
--color-brand-light: #B55838;
|
||||
--color-brand-subtle: oklch(0.65 0.12 30 / 0.08);
|
||||
|
||||
/* Surfaces — warm beige palette (not white) */
|
||||
--color-surface-0: #ECE9E0;
|
||||
--color-surface-1: #F5F3EC;
|
||||
--color-surface-2: #FDFCF8;
|
||||
--color-surface-3: #E8E6DC;
|
||||
/* Surfaces — warm stone gray (not yellow-beige) */
|
||||
--color-surface-0: #EFEEE9;
|
||||
--color-surface-1: #F6F5F2;
|
||||
--color-surface-2: #FBFAF8;
|
||||
--color-surface-3: #DFDDD8;
|
||||
|
||||
/* Text — warm dark tones */
|
||||
--color-text-primary: #141413;
|
||||
--color-text-secondary: #6B6860;
|
||||
--color-text-muted: #9C9890;
|
||||
/* Text — warm near-black tones */
|
||||
--color-text-primary: #1A1917;
|
||||
--color-text-secondary: #5E5A54;
|
||||
--color-text-muted: #969088;
|
||||
|
||||
/* Inverted — for user message bubbles */
|
||||
--color-bg-inverted: #2D2B27;
|
||||
--color-text-inverted: #F5F3EC;
|
||||
--color-bg-inverted: #2C2A27;
|
||||
--color-text-inverted: #F6F5F2;
|
||||
|
||||
/* Warning — warm yellow for permission panels */
|
||||
--color-warning-bg: #FEF3C7;
|
||||
--color-warning-border: #F59E0B;
|
||||
--color-warning-text: #92400E;
|
||||
/* User bubble — brand-tinted surface */
|
||||
--color-user-bubble: #C96442;
|
||||
--color-user-bubble-border: #B55838;
|
||||
|
||||
/* Warning — refined amber (less construction-zone) */
|
||||
--color-warning-bg: oklch(0.96 0.02 85);
|
||||
--color-warning-border: oklch(0.75 0.14 75);
|
||||
--color-warning-text: oklch(0.40 0.08 60);
|
||||
|
||||
/* Status */
|
||||
--color-status-active: #6B8F47;
|
||||
--color-status-running: #4A7FB5;
|
||||
--color-status-active: #5D8A3C;
|
||||
--color-status-running: #3D72A8;
|
||||
--color-status-idle: #7C3aed;
|
||||
--color-status-error: #C0453A;
|
||||
--color-status-warning: #C9943A;
|
||||
--color-status-error: #B83B31;
|
||||
--color-status-warning: #B88630;
|
||||
|
||||
/* Tool card */
|
||||
--color-tool-card: #F5F3EC;
|
||||
--color-tool-card: #F6F5F2;
|
||||
|
||||
/* shadcn/ui tokens (oklch — warm hue ~85) */
|
||||
--color-background: oklch(0.94 0.01 85);
|
||||
--color-foreground: oklch(0.20 0.01 85);
|
||||
--color-card: oklch(0.97 0.008 85);
|
||||
--color-card-foreground: oklch(0.20 0.01 85);
|
||||
--color-popover: oklch(0.97 0.008 85);
|
||||
--color-popover-foreground: oklch(0.20 0.01 85);
|
||||
--color-primary: oklch(0.20 0.01 85);
|
||||
--color-primary-foreground: oklch(0.96 0.01 85);
|
||||
--color-secondary: oklch(0.95 0.01 85);
|
||||
--color-secondary-foreground: oklch(0.20 0.01 85);
|
||||
--color-muted: oklch(0.93 0.01 85);
|
||||
--color-muted-foreground: oklch(0.50 0.02 85);
|
||||
--color-accent: oklch(0.95 0.01 85);
|
||||
--color-accent-foreground: oklch(0.20 0.01 85);
|
||||
/* shadcn/ui tokens (oklch — warm stone hue ~65) */
|
||||
--color-background: oklch(0.955 0.006 65);
|
||||
--color-foreground: oklch(0.19 0.008 65);
|
||||
--color-card: oklch(0.975 0.005 65);
|
||||
--color-card-foreground: oklch(0.19 0.008 65);
|
||||
--color-popover: oklch(0.975 0.005 65);
|
||||
--color-popover-foreground: oklch(0.19 0.008 65);
|
||||
--color-primary: oklch(0.19 0.008 65);
|
||||
--color-primary-foreground: oklch(0.97 0.005 65);
|
||||
--color-secondary: oklch(0.955 0.006 65);
|
||||
--color-secondary-foreground: oklch(0.19 0.008 65);
|
||||
--color-muted: oklch(0.935 0.006 65);
|
||||
--color-muted-foreground: oklch(0.48 0.015 65);
|
||||
--color-accent: oklch(0.955 0.006 65);
|
||||
--color-accent-foreground: oklch(0.19 0.008 65);
|
||||
--color-destructive: oklch(0.577 0.245 27.325);
|
||||
|
||||
/* Border / Input / Ring */
|
||||
--color-border: oklch(0.88 0.015 85);
|
||||
--color-border-light: #E8E6DC;
|
||||
--color-input: oklch(0.88 0.015 85);
|
||||
--color-ring: oklch(0.65 0.08 50);
|
||||
--color-border: oklch(0.905 0.008 65);
|
||||
--color-border-light: #DFDDD8;
|
||||
--color-input: oklch(0.905 0.008 65);
|
||||
--color-ring: oklch(0.65 0.10 40);
|
||||
|
||||
/* Default utility values */
|
||||
--default-border-color: var(--color-border);
|
||||
@@ -81,24 +87,26 @@
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Dark mode — warm dark palette
|
||||
Dark mode — warm stone dark palette
|
||||
============================================================ */
|
||||
.dark {
|
||||
--color-surface-0: #1C1B18;
|
||||
--color-surface-1: #242320;
|
||||
--color-surface-2: #2D2B27;
|
||||
--color-surface-3: #3A3832;
|
||||
--color-text-primary: #ECE9E0;
|
||||
--color-text-secondary: #9C9890;
|
||||
--color-text-muted: #6B6860;
|
||||
--color-bg-inverted: #F5F3EC;
|
||||
--color-text-inverted: #2D2B27;
|
||||
--color-border: #3A3832;
|
||||
--color-border-light: #2D2B27;
|
||||
--color-tool-card: #2D2B27;
|
||||
--color-warning-bg: #45290A;
|
||||
--color-warning-border: #B47818;
|
||||
--color-warning-text: #FCD980;
|
||||
--color-surface-0: #1A1917;
|
||||
--color-surface-1: #222120;
|
||||
--color-surface-2: #2C2A28;
|
||||
--color-surface-3: #3A3835;
|
||||
--color-text-primary: #EFEEE9;
|
||||
--color-text-secondary: #969088;
|
||||
--color-text-muted: #5E5A54;
|
||||
--color-bg-inverted: #F6F5F2;
|
||||
--color-text-inverted: #2C2A28;
|
||||
--color-user-bubble: #C96442;
|
||||
--color-user-bubble-border: #B55838;
|
||||
--color-border: #3A3835;
|
||||
--color-border-light: #2C2A28;
|
||||
--color-tool-card: #2C2A28;
|
||||
--color-warning-bg: oklch(0.22 0.03 75);
|
||||
--color-warning-border: oklch(0.62 0.12 75);
|
||||
--color-warning-text: oklch(0.82 0.08 85);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -124,6 +132,7 @@
|
||||
============================================================ */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
@@ -133,30 +142,15 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Glimmer sweep — reverse sweep highlight (same visual as TUI) */
|
||||
/* Glimmer sweep — opacity pulse for thinking indicator */
|
||||
.glimmer-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-text-secondary) 0%,
|
||||
var(--color-text-secondary) 40%,
|
||||
var(--color-brand) 50%,
|
||||
var(--color-text-secondary) 60%,
|
||||
var(--color-text-secondary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: glimmer-sweep 3s ease-in-out infinite;
|
||||
color: var(--color-text-secondary);
|
||||
animation: glimmer-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes glimmer-sweep {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
@keyframes glimmer-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -165,7 +159,7 @@
|
||||
|
||||
/* Chat input — warm orange focus ring */
|
||||
.chat-input-focus:focus-within {
|
||||
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.25);
|
||||
box-shadow: 0 0 0 2px oklch(0.65 0.12 30 / 0.20);
|
||||
}
|
||||
|
||||
/* Markdown content in message bubbles */
|
||||
@@ -189,6 +183,19 @@
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Reduced motion — respect user preference
|
||||
============================================================ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Animations — Anthropic entrance effects
|
||||
============================================================ */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Unified Chat Data Model — shared between ACP and RCS chat interfaces
|
||||
// =============================================================================
|
||||
|
||||
import type { ToolCallContent, PermissionOption } from "../acp/types";
|
||||
import type { ToolCallContent, PermissionOption, PlanEntry } from "../acp/types";
|
||||
|
||||
// 工具调用状态
|
||||
export type ToolCallStatus =
|
||||
@@ -62,11 +62,19 @@ export interface ToolCallEntry {
|
||||
toolCall: ToolCallData;
|
||||
}
|
||||
|
||||
// Plan 展示条目(Agent 执行计划)
|
||||
export interface PlanDisplayEntry {
|
||||
type: "plan";
|
||||
id: string;
|
||||
entries: PlanEntry[];
|
||||
}
|
||||
|
||||
// 统一聊天条目类型
|
||||
export type ThreadEntry =
|
||||
| UserMessageEntry
|
||||
| AssistantMessageEntry
|
||||
| ToolCallEntry;
|
||||
| ToolCallEntry
|
||||
| PlanDisplayEntry;
|
||||
|
||||
// =============================================================================
|
||||
// Chat 组件 Props 类型
|
||||
|
||||
@@ -35,13 +35,8 @@ export function Dashboard({ onNavigateSession }: DashboardProps) {
|
||||
onNavigateSession(session.id);
|
||||
};
|
||||
|
||||
const handleSelectEnvironment = useCallback((env: Environment) => {
|
||||
if (env.worker_type === "acp") {
|
||||
// Navigate to ACP agent detail page (same origin, shares UUID auth)
|
||||
window.history.pushState(null, "", `/acp/agent/${env.id}`);
|
||||
// Force page reload to load ACP app
|
||||
window.location.href = `/acp/agent/${env.id}`;
|
||||
}
|
||||
const handleSelectEnvironment = useCallback((_env: Environment) => {
|
||||
// ACP agents require WebSocket connection and cannot be navigated to directly
|
||||
// Bridge environments: no direct navigation (sessions are listed below)
|
||||
}, []);
|
||||
|
||||
@@ -51,6 +46,7 @@ export function Dashboard({ onNavigateSession }: DashboardProps) {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||
<h1 className="sr-only">Dashboard</h1>
|
||||
{/* Environments */}
|
||||
<section className="mb-10">
|
||||
<h2 className="mb-4 font-display text-lg font-semibold text-text-primary">Environments</h2>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "../api/client";
|
||||
import type { Session, SessionEvent } from "../types";
|
||||
import { isClosedSessionStatus, formatTime, cn } from "../lib/utils";
|
||||
import { Info } from "lucide-react";
|
||||
import { RCSChatAdapter } from "../lib/rcs-chat-adapter";
|
||||
import type { ThreadEntry, PendingPermission } from "../lib/types";
|
||||
import { StatusBadge } from "../components/Navbar";
|
||||
@@ -25,7 +26,6 @@ import { TooltipProvider } from "../../components/ui/tooltip";
|
||||
import { ACPClient, DisconnectRequestedError } from "../acp/client";
|
||||
import { createRelayClient } from "../acp/relay-client";
|
||||
import { ACPMain } from "../../components/ACPMain";
|
||||
import { StatusDot } from "../../components/ui/connection-status";
|
||||
|
||||
interface SessionDetailProps {
|
||||
sessionId: string;
|
||||
@@ -246,6 +246,7 @@ export function SessionDetail({ sessionId }: SessionDetailProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<h1 className="sr-only">{session.title || session.id}</h1>
|
||||
{/* Session Header */}
|
||||
<div className="border-b bg-surface-1 px-4 py-3">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
@@ -275,7 +276,7 @@ export function SessionDetail({ sessionId }: SessionDetailProps) {
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text-secondary transition-colors"
|
||||
title="Session info"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTaskPanelOpen(!taskPanelOpen)}
|
||||
@@ -434,24 +435,6 @@ function ACPSessionDetail({ sessionId, agentId }: { sessionId: string; agentId:
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-surface-1 px-4 py-3">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-1">
|
||||
<a href="/code/" className="text-sm text-text-muted hover:text-text-secondary transition-colors no-underline">
|
||||
← Dashboard
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot state={connectionState} />
|
||||
<h2 className="font-display text-lg font-semibold text-text-primary">
|
||||
{agentId}
|
||||
</h2>
|
||||
<span className="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">ACP</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && connectionState === "error" && (
|
||||
<div className="px-4 py-2 bg-destructive/10 text-destructive text-sm border-b">
|
||||
{error}
|
||||
@@ -478,7 +461,7 @@ function ACPSessionDetail({ sessionId, agentId }: { sessionId: string; agentId:
|
||||
|
||||
{client && connectionState === "connected" && (
|
||||
<div className="flex-1 min-h-0">
|
||||
<ACPMain client={client} />
|
||||
<ACPMain client={client} agentId={agentId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
1
scripts/rcs-ccb.sh
Normal file
1
scripts/rcs-ccb.sh
Normal file
@@ -0,0 +1 @@
|
||||
ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key acp-link ccb-bun -- --acp
|
||||
@@ -7,6 +7,7 @@ import { getIsNonInteractiveSession } from '../bootstrap/state.js'
|
||||
import { getCurrentWorktreeSession } from '../utils/worktree.js'
|
||||
import { getSessionStartDate } from './common.js'
|
||||
import { getInitialSettings } from '../utils/settings/settings.js'
|
||||
import { isPoorModeActive } from '../commands/poor/poorMode.js'
|
||||
import {
|
||||
AGENT_TOOL_NAME,
|
||||
VERIFICATION_AGENT_TYPE,
|
||||
@@ -391,7 +392,9 @@ function getSessionSpecificGuidanceSection(
|
||||
hasAgentTool &&
|
||||
feature('VERIFICATION_AGENT') &&
|
||||
// 3P default: false — verification agent is ant-only A/B
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false)
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
|
||||
// Poor mode: skip verification agent to save tokens
|
||||
!isPoorModeActive()
|
||||
? `The contract: when non-trivial implementation happens on your turn, independent adversarial verification must happen before you report completion \u2014 regardless of who did the implementing (you directly, a fork you spawned, or a subagent). You are the one reporting to the user; you own the gate. Non-trivial means: 3+ file edits, backend/API changes, or infrastructure changes. Spawn the ${AGENT_TOOL_NAME} tool with subagent_type="${VERIFICATION_AGENT_TYPE}". Your own checks, caveats, and a fork's self-checks do NOT substitute \u2014 only the verifier assigns a verdict; you cannot self-assign PARTIAL. Pass the original user request, all files changed (by anyone), the approach, and the plan file path if applicable. Flag concerns if you have them but do NOT share test results or claim things work. On FAIL: fix, resume the verifier with its findings plus your fix, repeat until PASS. On PASS: spot-check it \u2014 re-run 2-3 commands from its report, confirm every PASS has a Command run block with output that matches your re-run. If any PASS lacks a command block or diverges, resume the verifier with the specifics. On PARTIAL (from the verifier): report what passed and what could not be verified.`
|
||||
: null,
|
||||
].filter(item => item !== null)
|
||||
|
||||
@@ -263,8 +263,8 @@ function addToTotalModelUsage(
|
||||
maxOutputTokens: 0,
|
||||
}
|
||||
|
||||
modelUsage.inputTokens += usage.input_tokens
|
||||
modelUsage.outputTokens += usage.output_tokens
|
||||
modelUsage.inputTokens += usage.input_tokens ?? 0
|
||||
modelUsage.outputTokens += usage.output_tokens ?? 0
|
||||
modelUsage.cacheReadInputTokens += usage.cache_read_input_tokens ?? 0
|
||||
modelUsage.cacheCreationInputTokens += usage.cache_creation_input_tokens ?? 0
|
||||
modelUsage.webSearchRequests +=
|
||||
|
||||
@@ -459,10 +459,13 @@ export class AcpAgent implements Agent {
|
||||
const permissionContext = getEmptyToolPermissionContext()
|
||||
const tools: Tools = getTools(permissionContext)
|
||||
|
||||
// Parse permission mode from settings
|
||||
// Parse permission mode from _meta (passed by RCS/acp-link) or fall back to settings
|
||||
const metaPermissionMode = (params._meta as Record<string, unknown> | null | undefined)?.permissionMode as string | undefined
|
||||
console.log('[ACP Agent] Session create _meta:', JSON.stringify(params._meta), 'extracted mode:', metaPermissionMode)
|
||||
const permissionMode = resolvePermissionMode(
|
||||
this.getSetting<string>('permissions.defaultMode'),
|
||||
metaPermissionMode ?? this.getSetting<string>('permissions.defaultMode'),
|
||||
)
|
||||
console.log('[ACP Agent] Resolved permissionMode:', permissionMode)
|
||||
|
||||
// Create the permission bridge canUseTool function
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
@@ -471,17 +474,24 @@ export class AcpAgent implements Agent {
|
||||
() => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default',
|
||||
this.clientCapabilities,
|
||||
cwd,
|
||||
(modeId: string) => { this.applySessionMode(sessionId, modeId) },
|
||||
)
|
||||
|
||||
// Parse MCP servers from ACP params
|
||||
// MCP server config is handled separately in the tools system
|
||||
|
||||
// Check if bypass permissions is available (not running as root unless in sandbox)
|
||||
const isBypassAvailable =
|
||||
(typeof process.geteuid === 'function' ? process.geteuid() !== 0 : true) ||
|
||||
!!process.env.IS_SANDBOX
|
||||
|
||||
// Create a mutable AppState for the session
|
||||
const appState: AppState = {
|
||||
...getDefaultAppState(),
|
||||
toolPermissionContext: {
|
||||
...permissionContext,
|
||||
mode: permissionMode as PermissionMode,
|
||||
isBypassPermissionsModeAvailable: isBypassAvailable,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -666,6 +676,11 @@ export class AcpAgent implements Agent {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
session.modes = { ...session.modes, currentModeId: modeId }
|
||||
// Sync mode to appState so the permission pipeline sees the correct mode
|
||||
session.appState.toolPermissionContext = {
|
||||
...session.appState.toolPermissionContext,
|
||||
mode: modeId as PermissionMode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
} from '../../types/permissions.js'
|
||||
import type { Tool as ToolType, ToolUseContext } from '../../Tool.js'
|
||||
import type { AssistantMessage } from '../../types/message.js'
|
||||
import { hasPermissionsToUseTool } from '../../utils/permissions/permissions.js'
|
||||
import { toolInfoFromToolUse } from './bridge.js'
|
||||
|
||||
const IS_ROOT =
|
||||
@@ -42,31 +43,52 @@ export function createAcpCanUseTool(
|
||||
getCurrentMode: () => string,
|
||||
clientCapabilities?: ClientCapabilities,
|
||||
cwd?: string,
|
||||
onModeChange?: (modeId: string) => void,
|
||||
): CanUseToolFn {
|
||||
return async (
|
||||
tool: ToolType,
|
||||
input: Record<string, unknown>,
|
||||
_context: ToolUseContext,
|
||||
_assistantMessage: AssistantMessage,
|
||||
context: ToolUseContext,
|
||||
assistantMessage: AssistantMessage,
|
||||
toolUseID: string,
|
||||
_forceDecision?: PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision,
|
||||
forceDecision?: PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision,
|
||||
): Promise<PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision> => {
|
||||
const supportsTerminalOutput = checkTerminalOutput(clientCapabilities)
|
||||
|
||||
// ── ExitPlanMode special handling ────────────────────────────
|
||||
if (tool.name === 'ExitPlanMode') {
|
||||
return handleExitPlanMode(conn, sessionId, toolUseID, input, supportsTerminalOutput, cwd)
|
||||
return handleExitPlanMode(
|
||||
conn, sessionId, toolUseID, input, supportsTerminalOutput, cwd, onModeChange,
|
||||
)
|
||||
}
|
||||
|
||||
// ── bypassPermissions mode ───────────────────────────────────
|
||||
if (getCurrentMode() === 'bypassPermissions') {
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
// ── Force decision bypass (used by coordinator/swarm workers) ──
|
||||
if (forceDecision !== undefined) {
|
||||
return forceDecision
|
||||
}
|
||||
|
||||
// ── Run through the normal permission pipeline ────────────────
|
||||
// This handles: deny rules, allow rules, tool-specific checks,
|
||||
// bypassPermissions mode, dontAsk mode, acceptEdits mode, auto mode classifier
|
||||
try {
|
||||
const pipelineResult = await hasPermissionsToUseTool(
|
||||
tool, input, context, assistantMessage, toolUseID,
|
||||
)
|
||||
|
||||
// If the pipeline resolved to allow or deny, return that
|
||||
if (pipelineResult.behavior === 'allow') {
|
||||
return pipelineResult as PermissionAllowDecision
|
||||
}
|
||||
if (pipelineResult.behavior === 'deny') {
|
||||
return pipelineResult as PermissionDenyDecision
|
||||
}
|
||||
// behavior === 'ask' → fall through to client delegation
|
||||
} catch (err) {
|
||||
// If the pipeline fails, fall through to client delegation
|
||||
console.error('[ACP Permissions] Pipeline error, falling back to client:', err)
|
||||
}
|
||||
|
||||
// ── Standard tool permission ─────────────────────────────────
|
||||
// ── Delegate to ACP client for interactive permission decision ──
|
||||
const info = toolInfoFromToolUse(
|
||||
{ name: tool.name, id: toolUseID, input },
|
||||
supportsTerminalOutput,
|
||||
@@ -139,6 +161,7 @@ async function handleExitPlanMode(
|
||||
input: Record<string, unknown>,
|
||||
supportsTerminalOutput: boolean,
|
||||
cwd?: string,
|
||||
onModeChange?: (modeId: string) => void,
|
||||
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
|
||||
const options: Array<PermissionOption> = [
|
||||
{ kind: 'allow_always', name: 'Yes, and use "auto" mode', optionId: 'auto' },
|
||||
@@ -194,6 +217,9 @@ async function handleExitPlanMode(
|
||||
selectedOption === 'auto' ||
|
||||
selectedOption === 'bypassPermissions'
|
||||
) {
|
||||
// Sync mode to session state and appState
|
||||
onModeChange?.(selectedOption)
|
||||
|
||||
await conn.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
|
||||
@@ -2238,6 +2238,7 @@ async function* queryModel(
|
||||
const m: AssistantMessage = {
|
||||
message: {
|
||||
...partialMessage,
|
||||
usage: partialMessage.usage ?? { ...EMPTY_USAGE },
|
||||
content: normalizeContentFromAPI(
|
||||
[contentBlock] as BetaContentBlock[],
|
||||
tools,
|
||||
|
||||
@@ -106,7 +106,10 @@ export function updateProgressFromMessage(
|
||||
if (message.type !== 'assistant') {
|
||||
return
|
||||
}
|
||||
const usage = message.message!.usage as BetaUsage
|
||||
const usage = message.message!.usage as BetaUsage | undefined
|
||||
if (!usage) {
|
||||
return
|
||||
}
|
||||
// Keep latest input (it's cumulative in the API), sum outputs
|
||||
tracker.latestInputTokens =
|
||||
(usage.input_tokens as number) +
|
||||
|
||||
@@ -46,11 +46,14 @@ function getAssistantMessageId(message: Message): string | undefined {
|
||||
* Use tokenCountWithEstimation() when you need context size from messages.
|
||||
*/
|
||||
export function getTokenCountFromUsage(usage: Usage): number {
|
||||
if (!usage) {
|
||||
return 0
|
||||
}
|
||||
return (
|
||||
usage.input_tokens +
|
||||
(usage.input_tokens ?? 0) +
|
||||
(usage.cache_creation_input_tokens ?? 0) +
|
||||
(usage.cache_read_input_tokens ?? 0) +
|
||||
usage.output_tokens
|
||||
(usage.output_tokens ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user