Compare commits
1 Commits
rcc/ui-pol
...
rcc/parity
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e722fa013 |
343
PARITY.md
343
PARITY.md
@@ -1,214 +1,253 @@
|
|||||||
# PARITY GAP ANALYSIS
|
# PARITY Gap Analysis
|
||||||
|
|
||||||
Scope: read-only comparison between the original TypeScript source at `/home/bellman/Workspace/claude-code/src/` and the Rust port under `rust/crates/`.
|
Date: 2026-04-01
|
||||||
|
|
||||||
Method: compared feature surfaces, registries, entrypoints, and runtime plumbing only. No TypeScript source was copied.
|
Scope compared:
|
||||||
|
- Upstream TypeScript: `/home/bellman/Workspace/claude-code/src/`
|
||||||
|
- Rust port: `rust/crates/`
|
||||||
|
|
||||||
|
Method:
|
||||||
|
- Read-only comparison only.
|
||||||
|
- No upstream source was copied into this repo.
|
||||||
|
- This is a focused feature-gap report for `tools`, `hooks`, `plugins`, `skills`, `cli`, `assistant`, and `services`.
|
||||||
|
|
||||||
## Executive summary
|
## Executive summary
|
||||||
|
|
||||||
The Rust port has a good foundation for:
|
The Rust port has a solid core for:
|
||||||
- Anthropic API/OAuth basics
|
- basic prompt/REPL flow
|
||||||
- local conversation/session state
|
- session/runtime state
|
||||||
- a core tool loop
|
- Anthropic API/OAuth plumbing
|
||||||
- MCP stdio/bootstrap support
|
- a compact MVP tool registry
|
||||||
- CLAUDE.md discovery
|
- CLAUDE.md discovery
|
||||||
- a small but usable built-in tool set
|
- MCP config parsing/bootstrap primitives
|
||||||
|
|
||||||
It is **not feature-parity** with the TypeScript CLI.
|
But it is still materially behind the TypeScript implementation in six major areas:
|
||||||
|
1. **Tools surface area** is much smaller.
|
||||||
|
2. **Hook execution** is largely missing; Rust mostly loads hook config but does not run a TS-style PreToolUse/PostToolUse pipeline.
|
||||||
|
3. **Plugins** are effectively absent in Rust.
|
||||||
|
4. **Skills** are only partially implemented in Rust via direct `SKILL.md` loading; there is no comparable skills command/discovery/registration surface.
|
||||||
|
5. **CLI** breadth is much narrower in Rust.
|
||||||
|
6. **Assistant/tool orchestration** lacks the richer streaming concurrency, hook integration, and orchestration behavior present in TS.
|
||||||
|
7. **Services** in Rust cover API/auth/runtime basics, but many higher-level TS services are missing.
|
||||||
|
|
||||||
Largest gaps:
|
## Critical bug status on this branch
|
||||||
- **plugins** are effectively absent in Rust
|
|
||||||
- **hooks** are parsed but not executed in Rust
|
Targeted critical items requested by the user:
|
||||||
- **CLI breadth** is much narrower in Rust
|
- **Prompt mode tools enabled**: fixed in `rust/crates/rusty-claude-cli/src/main.rs:75-82`
|
||||||
- **skills** are local-file only in Rust, without the TS registry/bundled pipeline
|
- **Default permission mode = danger-full-access**: fixed in `rust/crates/rusty-claude-cli/src/args.rs:12-16`, `rust/crates/rusty-claude-cli/src/main.rs:348-353`, and starter config `rust/crates/rusty-claude-cli/src/init.rs:4-9`
|
||||||
- **assistant orchestration** lacks TS hook-aware orchestration and remote/structured transports
|
- **Tool input `{}` prefix bug**: fixed/guarded in streaming vs non-stream paths at `rust/crates/rusty-claude-cli/src/main.rs:2211-2256`
|
||||||
- **services** beyond core API/OAuth/MCP are mostly missing in Rust
|
- **Unlimited max_iterations**: already present at `rust/crates/runtime/src/conversation.rs:143-148` with `usize::MAX` initialization at `rust/crates/runtime/src/conversation.rs:119`
|
||||||
|
|
||||||
|
Build/test/manual verification is tracked separately below and must pass before the branch is considered done.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## tools/
|
## 1) tools/
|
||||||
|
|
||||||
### TS exists
|
### Upstream TS has
|
||||||
Evidence:
|
- Large per-tool module surface under `src/tools/`, including agent/task tools, AskUserQuestion, MCP tools, plan/worktree tools, REPL, schedule/task tools, synthetic output, brief/upload, and more.
|
||||||
- `src/tools/` contains broad tool families including `AgentTool`, `AskUserQuestionTool`, `BashTool`, `ConfigTool`, `FileReadTool`, `FileWriteTool`, `GlobTool`, `GrepTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `SkillTool`, `Task*`, `Team*`, `TodoWriteTool`, `ToolSearchTool`, `WebFetchTool`, `WebSearchTool`.
|
- Evidence:
|
||||||
- Tool execution/orchestration is split across `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolHooks.ts`, and `src/services/tools/toolOrchestration.ts`.
|
- `src/tools/AgentTool/AgentTool.tsx`
|
||||||
|
- `src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx`
|
||||||
|
- `src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts`
|
||||||
|
- `src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts`
|
||||||
|
- `src/tools/EnterPlanModeTool/EnterPlanModeTool.ts`
|
||||||
|
- `src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts`
|
||||||
|
- `src/tools/EnterWorktreeTool/EnterWorktreeTool.ts`
|
||||||
|
- `src/tools/ExitWorktreeTool/ExitWorktreeTool.ts`
|
||||||
|
- `src/tools/RemoteTriggerTool/RemoteTriggerTool.ts`
|
||||||
|
- `src/tools/ScheduleCronTool/*`
|
||||||
|
- `src/tools/TaskCreateTool/*`, `TaskGetTool/*`, `TaskListTool/*`, `TaskOutputTool/*`
|
||||||
|
|
||||||
### Rust exists
|
### Rust currently has
|
||||||
Evidence:
|
- A single MVP registry in `rust/crates/tools/src/lib.rs:53-371`.
|
||||||
- Tool registry is centralized in `rust/crates/tools/src/lib.rs` via `mvp_tool_specs()`.
|
- Implemented tools include `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, `grep_search`, `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `StructuredOutput`, `REPL`, `PowerShell`.
|
||||||
- Current built-ins include shell/file/search/web/todo/skill/agent/config/notebook/repl/powershell primitives.
|
|
||||||
- Runtime execution is wired through `rust/crates/tools/src/lib.rs` and `rust/crates/runtime/src/conversation.rs`.
|
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### Missing or broken in Rust
|
||||||
- No Rust equivalents for major TS tools such as `AskUserQuestionTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `Task*`, `Team*`, and several workflow/system tools.
|
- **Missing large chunks of the upstream tool catalog**: I did not find Rust equivalents for AskUserQuestion, MCP resource listing/reading tools, plan/worktree entry/exit tools, task management tools, remote trigger, synthetic output, or schedule/cron tools.
|
||||||
- Rust tool surface is still explicitly an MVP registry, not a parity registry.
|
- **Tool decomposition is much coarser**: TS isolates tool-specific validation/security/UI behavior per tool module; Rust centralizes almost everything in one file (`rust/crates/tools/src/lib.rs`).
|
||||||
- Rust lacks TS’s layered tool orchestration split.
|
- **Likely parity impact**: lower fidelity tool prompting, weaker per-tool behavior specialization, and fewer native tool choices exposed to the model.
|
||||||
|
|
||||||
**Status:** partial core only.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## hooks/
|
## 2) hooks/
|
||||||
|
|
||||||
### TS exists
|
### Upstream TS has
|
||||||
Evidence:
|
- A full permission and tool-hook system with **PermissionRequest**, **PreToolUse**, **PostToolUse**, and failure/cancellation handling.
|
||||||
- Hook command surface under `src/commands/hooks/`.
|
- Evidence:
|
||||||
- Runtime hook machinery in `src/services/tools/toolHooks.ts` and `src/services/tools/toolExecution.ts`.
|
- `src/hooks/toolPermission/PermissionContext.ts:25,222`
|
||||||
- TS supports `PreToolUse`, `PostToolUse`, and broader hook-driven behaviors configured through settings and documented in `src/skills/bundled/updateConfig.ts`.
|
- `src/hooks/toolPermission/handlers/coordinatorHandler.ts:32-38`
|
||||||
|
- `src/hooks/toolPermission/handlers/interactiveHandler.ts:412-429`
|
||||||
|
- `src/services/tools/toolHooks.ts:39,435`
|
||||||
|
- `src/services/tools/toolExecution.ts:800,1074,1483`
|
||||||
|
- `src/commands/hooks/index.ts:5-8`
|
||||||
|
|
||||||
### Rust exists
|
### Rust currently has
|
||||||
Evidence:
|
- Hook data is **loaded/merged from config** and visible in reports:
|
||||||
- Hook config is parsed and merged in `rust/crates/runtime/src/config.rs`.
|
- `rust/crates/runtime/src/config.rs:786-797,829-838`
|
||||||
- Hook config can be inspected via Rust config reporting in `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`.
|
- `rust/crates/rusty-claude-cli/src/main.rs:1665-1669`
|
||||||
- Prompt guidance mentions hooks in `rust/crates/runtime/src/prompt.rs`.
|
- The system prompt acknowledges user-configured hooks:
|
||||||
|
- `rust/crates/runtime/src/prompt.rs:452-459`
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### Missing or broken in Rust
|
||||||
- No actual hook execution pipeline in `rust/crates/runtime/src/conversation.rs`.
|
- **No comparable hook execution pipeline found** in the Rust runtime conversation/tool execution path.
|
||||||
- No PreToolUse/PostToolUse mutation/deny/rewrite/result-hook behavior.
|
- `rust/crates/runtime/src/conversation.rs:151-208` goes straight from assistant tool_use -> permission check -> tool execute -> tool_result, without TS-style PreToolUse/PostToolUse processing.
|
||||||
- No Rust `/hooks` parity command.
|
- I did **not** find Rust counterparts to TS files like `toolHooks.ts` or `PermissionContext.ts` that execute hook callbacks and alter/block tool behavior.
|
||||||
|
- Result: Rust appears to support **hook configuration visibility**, but not full **hook behavior parity**.
|
||||||
**Status:** config-only; runtime behavior missing.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## plugins/
|
## 3) plugins/
|
||||||
|
|
||||||
### TS exists
|
### Upstream TS has
|
||||||
Evidence:
|
- Built-in and bundled plugin registration plus CLI/service support for validate/list/install/uninstall/enable/disable/update flows.
|
||||||
- Built-in plugin scaffolding in `src/plugins/builtinPlugins.ts` and `src/plugins/bundled/index.ts`.
|
- Evidence:
|
||||||
- Plugin lifecycle/services in `src/services/plugins/PluginInstallationManager.ts` and `src/services/plugins/pluginOperations.ts`.
|
- `src/plugins/builtinPlugins.ts:7-17,149-150`
|
||||||
- CLI/plugin command surface under `src/commands/plugin/` and `src/commands/reload-plugins/`.
|
- `src/plugins/bundled/index.ts:7-22`
|
||||||
|
- `src/cli/handlers/plugins.ts:51,101,157,668`
|
||||||
|
- `src/services/plugins/pluginOperations.ts:16,54,306,435,713`
|
||||||
|
- `src/services/plugins/pluginCliCommands.ts:7,36`
|
||||||
|
|
||||||
### Rust exists
|
### Rust currently has
|
||||||
Evidence:
|
- I did **not** find a dedicated plugin crate/module/handler under `rust/crates/`.
|
||||||
- No dedicated plugin subsystem appears under `rust/crates/`.
|
- The Rust crate layout is only `api`, `commands`, `compat-harness`, `runtime`, `rusty-claude-cli`, and `tools`.
|
||||||
- Repo-wide Rust references to plugins are effectively absent beyond text/help mentions.
|
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### Missing or broken in Rust
|
||||||
- No plugin loader.
|
- **Plugin loading/install/update/validation is missing.**
|
||||||
- No marketplace install/update/enable/disable flow.
|
- **No plugin CLI surface found** comparable to `claude plugin ...`.
|
||||||
- No `/plugin` or `/reload-plugins` parity.
|
- **No plugin runtime refresh/reconciliation layer found**.
|
||||||
- No plugin-provided hook/tool/command/MCP extension path.
|
- This is one of the largest parity gaps.
|
||||||
|
|
||||||
**Status:** missing.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## skills/ and CLAUDE.md discovery
|
## 4) skills/
|
||||||
|
|
||||||
### TS exists
|
### Upstream TS has
|
||||||
Evidence:
|
- Bundled skills registry and loader integration, plus a `skills` command.
|
||||||
- Skill loading/registry pipeline in `src/skills/loadSkillsDir.ts`, `src/skills/bundledSkills.ts`, and `src/skills/mcpSkillBuilders.ts`.
|
- Evidence:
|
||||||
- Bundled skills under `src/skills/bundled/`.
|
- `src/commands/skills/index.ts:6`
|
||||||
- Skills command surface under `src/commands/skills/`.
|
- `src/skills/bundledSkills.ts:44,99,107,114`
|
||||||
|
- `src/skills/loadSkillsDir.ts:65`
|
||||||
|
- `src/skills/mcpSkillBuilders.ts:4-21,40`
|
||||||
|
|
||||||
### Rust exists
|
### Rust currently has
|
||||||
Evidence:
|
- A `Skill` tool that loads local `SKILL.md` files directly:
|
||||||
- `Skill` tool in `rust/crates/tools/src/lib.rs` resolves and reads local `SKILL.md` files.
|
- `rust/crates/tools/src/lib.rs:1244-1255`
|
||||||
- CLAUDE.md discovery is implemented in `rust/crates/runtime/src/prompt.rs`.
|
- `rust/crates/tools/src/lib.rs:1288-1323`
|
||||||
- Rust supports `/memory` and `/init` via `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`.
|
- CLAUDE.md / instruction discovery exists in runtime prompt loading:
|
||||||
|
- `rust/crates/runtime/src/prompt.rs:203-208`
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### Missing or broken in Rust
|
||||||
- No bundled skill registry equivalent.
|
- **No Rust `/skills` slash command** in `rust/crates/commands/src/lib.rs:41-166`.
|
||||||
- No `/skills` command.
|
- **No visible bundled-skill registry equivalent** to TS `bundledSkills.ts` / `loadSkillsDir.ts` / `mcpSkillBuilders.ts`.
|
||||||
- No MCP skill-builder pipeline.
|
- Current Rust skill support is closer to **direct file loading** than full upstream **skill discovery/registration/command integration**.
|
||||||
- No TS-style live skill discovery/reload/change handling.
|
|
||||||
- No comparable session-memory / team-memory integration around skills.
|
|
||||||
|
|
||||||
**Status:** basic local skill loading only.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## cli/
|
## 5) cli/
|
||||||
|
|
||||||
### TS exists
|
### Upstream TS has
|
||||||
Evidence:
|
- Broad CLI handler and transport surface.
|
||||||
- Large command surface under `src/commands/` including `agents`, `hooks`, `mcp`, `memory`, `model`, `permissions`, `plan`, `plugin`, `resume`, `review`, `skills`, `tasks`, and many more.
|
- Evidence:
|
||||||
- Structured/remote transport stack in `src/cli/structuredIO.ts`, `src/cli/remoteIO.ts`, and `src/cli/transports/*`.
|
- `src/cli/handlers/agents.ts:2-32`
|
||||||
- CLI handler split in `src/cli/handlers/*`.
|
- `src/cli/handlers/auth.ts`
|
||||||
|
- `src/cli/handlers/autoMode.ts:24,35,73`
|
||||||
|
- `src/cli/handlers/plugins.ts:2-3,101,157,668`
|
||||||
|
- `src/cli/remoteIO.ts:25-35,118-127`
|
||||||
|
- `src/cli/transports/SSETransport.ts`
|
||||||
|
- `src/cli/transports/WebSocketTransport.ts`
|
||||||
|
- `src/cli/transports/HybridTransport.ts`
|
||||||
|
- `src/cli/transports/SerialBatchEventUploader.ts`
|
||||||
|
- `src/cli/transports/WorkerStateUploader.ts`
|
||||||
|
|
||||||
### Rust exists
|
### Rust currently has
|
||||||
Evidence:
|
- Minimal top-level subcommands in `rust/crates/rusty-claude-cli/src/args.rs:29-39` and `rust/crates/rusty-claude-cli/src/main.rs:67-90,242-261`.
|
||||||
- Shared slash command registry in `rust/crates/commands/src/lib.rs`.
|
- Slash command surface is 15 commands total in `rust/crates/commands/src/lib.rs:41-166,389`.
|
||||||
- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `memory`, `init`, `diff`, `version`, `export`, `session`.
|
|
||||||
- Main CLI/repl/prompt handling lives in `rust/crates/rusty-claude-cli/src/main.rs`.
|
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### Missing or broken in Rust
|
||||||
- Missing major TS command families: `/agents`, `/hooks`, `/mcp`, `/plugin`, `/skills`, `/plan`, `/review`, `/tasks`, and many others.
|
- **Missing major CLI subcommand families**: agents, plugins, mcp management, auto-mode tooling, and many other TS commands.
|
||||||
- No Rust equivalent to TS structured IO / remote transport layers.
|
- **Missing remote/transport stack parity**: I did not find Rust equivalents to TS remote structured IO / SSE / websocket / CCR transport layers.
|
||||||
- No TS-style handler decomposition for auth/plugins/MCP/agents.
|
- **Slash command breadth is much narrower** than TS command inventory under `src/commands/`.
|
||||||
- JSON prompt mode is improved on this branch, but still not clean transport parity: empirical verification shows tool-capable JSON output can emit human-readable tool-result lines before the final JSON object.
|
- **Prompt-mode parity bug** was present and is now fixed for this branch’s prompt path.
|
||||||
|
|
||||||
**Status:** functional local CLI core, much narrower than TS.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## assistant/ (agentic loop, streaming, tool calling)
|
## 6) assistant/
|
||||||
|
|
||||||
### TS exists
|
### Upstream TS has
|
||||||
Evidence:
|
- Rich tool orchestration and streaming execution behavior, including concurrency/cancellation/fallback logic.
|
||||||
- Assistant/session surface at `src/assistant/sessionHistory.ts`.
|
- Evidence:
|
||||||
- Tool orchestration in `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolOrchestration.ts`.
|
- `src/services/tools/StreamingToolExecutor.ts:35-214`
|
||||||
- Remote/structured streaming layers in `src/cli/structuredIO.ts` and `src/cli/remoteIO.ts`.
|
- `src/services/tools/toolExecution.ts:455-569,800-918,1483`
|
||||||
|
- `src/services/tools/toolOrchestration.ts:134-167`
|
||||||
|
- `src/assistant/sessionHistory.ts`
|
||||||
|
|
||||||
### Rust exists
|
### Rust currently has
|
||||||
Evidence:
|
- A straightforward agentic loop in `rust/crates/runtime/src/conversation.rs:130-214`.
|
||||||
- Core loop in `rust/crates/runtime/src/conversation.rs`.
|
- Streaming API adaptation in `rust/crates/rusty-claude-cli/src/main.rs:1998-2058`.
|
||||||
- Stream/tool event translation in `rust/crates/rusty-claude-cli/src/main.rs`.
|
- Tool-use block assembly and non-stream fallback handling in `rust/crates/rusty-claude-cli/src/main.rs:2211-2256`.
|
||||||
- Session persistence in `rust/crates/runtime/src/session.rs`.
|
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### Missing or broken in Rust
|
||||||
- No TS-style hook-aware orchestration layer.
|
- **No TS-style streaming tool executor** with sibling cancellation / fallback discard semantics.
|
||||||
- No TS structured/remote assistant transport stack.
|
- **No integrated PreToolUse/PostToolUse hook participation** in assistant execution.
|
||||||
- No richer TS assistant/session-history/background-task integration.
|
- **No comparable orchestration layer for richer tool event semantics** found.
|
||||||
- JSON output path is no longer single-turn only on this branch, but output cleanliness still lags TS transport expectations.
|
- Historically broken parity items in prompt mode were:
|
||||||
|
- prompt tool enablement (`main.rs:75-82`) — now fixed on this branch
|
||||||
**Status:** strong core loop, missing orchestration layers.
|
- streamed `{}` tool-input prefix behavior (`main.rs:2211-2256`) — now fixed/guarded on this branch
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## services/ (API client, auth, models, MCP)
|
## 7) services/
|
||||||
|
|
||||||
### TS exists
|
### Upstream TS has
|
||||||
Evidence:
|
- Very broad service layer, including API, analytics, compact/session memory, prompt suggestions, plugin services, MCP service helpers, LSP management, policy limits, team memory sync, notifier/tips, etc.
|
||||||
- API services under `src/services/api/*`.
|
- Evidence:
|
||||||
- OAuth services under `src/services/oauth/*`.
|
- `src/services/api/client.ts`, `src/services/api/claude.ts`, `src/services/api/withRetry.ts`
|
||||||
- MCP services under `src/services/mcp/*`.
|
- `src/services/oauth/client.ts`, `src/services/oauth/index.ts`
|
||||||
- Additional service layers for analytics, prompt suggestion, session memory, plugin operations, settings sync, policy limits, team memory sync, notifier, voice, and more under `src/services/*`.
|
- `src/services/mcp/*`
|
||||||
|
- `src/services/plugins/*`
|
||||||
|
- `src/services/lsp/*`
|
||||||
|
- `src/services/compact/*`
|
||||||
|
- `src/services/SessionMemory/*`
|
||||||
|
- `src/services/PromptSuggestion/*`
|
||||||
|
- `src/services/analytics/*`
|
||||||
|
- `src/services/teamMemorySync/*`
|
||||||
|
|
||||||
### Rust exists
|
### Rust currently has
|
||||||
Evidence:
|
- Core service equivalents for:
|
||||||
- Core Anthropic API client in `rust/crates/api/src/{client,error,sse,types}.rs`.
|
- API client + SSE: `rust/crates/api/src/client.rs`, `rust/crates/api/src/sse.rs`, `rust/crates/api/src/types.rs`
|
||||||
- OAuth support in `rust/crates/runtime/src/oauth.rs`.
|
- OAuth: `rust/crates/runtime/src/oauth.rs`
|
||||||
- MCP config/bootstrap/client support in `rust/crates/runtime/src/{config,mcp,mcp_client,mcp_stdio}.rs`.
|
- MCP config/bootstrap primitives: `rust/crates/runtime/src/mcp.rs`, `rust/crates/runtime/src/mcp_client.rs`, `rust/crates/runtime/src/mcp_stdio.rs`, `rust/crates/runtime/src/config.rs`
|
||||||
- Usage accounting in `rust/crates/runtime/src/usage.rs`.
|
- prompt/context loading: `rust/crates/runtime/src/prompt.rs`
|
||||||
- Remote upstream-proxy support in `rust/crates/runtime/src/remote.rs`.
|
- session compaction/runtime usage: `rust/crates/runtime/src/compact.rs`, `rust/crates/runtime/src/usage.rs`
|
||||||
|
|
||||||
### Missing or broken in Rust
|
### Missing or broken in Rust
|
||||||
- Most TS service ecosystem beyond core messaging/auth/MCP is absent.
|
- **Missing many higher-level services**: analytics, plugin services, prompt suggestion, team memory sync, richer LSP service management, notifier/tips ecosystem, and much of the surrounding product/service scaffolding.
|
||||||
- No TS-equivalent plugin service layer.
|
- Rust is closer to a **runtime/API core** than a full parity implementation of the TS service layer.
|
||||||
- No TS-equivalent analytics/settings-sync/policy-limit/team-memory subsystems.
|
|
||||||
- No TS-style MCP connection-manager/UI layer.
|
|
||||||
- Model/provider ergonomics remain thinner than TS.
|
|
||||||
|
|
||||||
**Status:** core foundation exists; broader service ecosystem missing.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Critical bug status in this worktree
|
## Highest-priority parity gaps after the critical bug fixes
|
||||||
|
|
||||||
### Fixed
|
1. **Hook execution parity**
|
||||||
- **Prompt mode tools enabled**
|
- Config exists, execution does not appear to.
|
||||||
- `rust/crates/rusty-claude-cli/src/main.rs` now constructs prompt mode with `LiveCli::new(model, true, ...)`.
|
- This affects permissions, tool interception, and continuation behavior.
|
||||||
- **Default permission mode = DangerFullAccess**
|
|
||||||
- Runtime default now resolves to `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/main.rs`.
|
|
||||||
- Clap default also uses `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/args.rs`.
|
|
||||||
- Init template writes `dontAsk` in `rust/crates/rusty-claude-cli/src/init.rs`.
|
|
||||||
- **Streaming `{}` tool-input prefix bug**
|
|
||||||
- `rust/crates/rusty-claude-cli/src/main.rs` now strips the initial empty object only for streaming tool input, while preserving legitimate `{}` in non-stream responses.
|
|
||||||
- **Unlimited max_iterations**
|
|
||||||
- Verified at `rust/crates/runtime/src/conversation.rs` with `usize::MAX`.
|
|
||||||
|
|
||||||
### Remaining notable parity issue
|
2. **Plugin system parity**
|
||||||
- **JSON prompt output cleanliness**
|
- Entire install/load/manage surface appears missing.
|
||||||
- Tool-capable JSON mode now loops, but empirical verification still shows pre-JSON human-readable tool-result output when tools fire.
|
|
||||||
|
3. **CLI breadth parity**
|
||||||
|
- Missing many upstream command families and remote transports.
|
||||||
|
|
||||||
|
4. **Tool surface parity**
|
||||||
|
- MVP tool registry exists, but a large number of upstream tool types are absent.
|
||||||
|
|
||||||
|
5. **Assistant orchestration parity**
|
||||||
|
- Core loop exists, but advanced streaming/execution behaviors from TS are missing.
|
||||||
|
|
||||||
|
## Recommended next work after current critical fixes
|
||||||
|
|
||||||
|
1. Finish build/test/manual verification of the critical bug patch.
|
||||||
|
2. Implement **hook execution** before broadening the tool surface further.
|
||||||
|
3. Decide whether **plugins** are in-scope for parity; if yes, this likely needs dedicated design work, not a small patch.
|
||||||
|
4. Expand the CLI/tool matrix deliberately rather than adding one-off commands without shared orchestration support.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"messages":[{"blocks":[{"text":"hello use bash tool for testing","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nHello! I'm ready to help. Let me run a quick bash command to confirm everything is working:","type":"text"},{"id":"toolu_01EuTzVfUK7iPRBvjZAovzfV","input":"{\"command\": \"echo \\\"Hello! Bash tool is working. 🎉\\\" && date && pwd\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4277,"output_tokens":92}},{"blocks":[{"is_error":false,"output":"{\n \"stdout\": \"Hello! Bash tool is working. 🎉\\nWed Apr 1 02:25:46 AM UTC 2026\\n/home/bellman/Workspace/clawd-code/rust\\n\",\n \"stderr\": \"\",\n \"rawOutputPath\": null,\n \"interrupted\": false,\n \"isImage\": null,\n \"backgroundTaskId\": null,\n \"backgroundedByUser\": null,\n \"assistantAutoBackgrounded\": null,\n \"dangerouslyDisableSandbox\": null,\n \"returnCodeInterpretation\": null,\n \"noOutputExpected\": false,\n \"structuredContent\": null,\n \"persistedOutputPath\": null,\n \"persistedOutputSize\": null,\n \"sandboxStatus\": {\n \"enabled\": true,\n \"requested\": {\n \"enabled\": true,\n \"namespace_restrictions\": true,\n \"network_isolation\": false,\n \"filesystem_mode\": \"workspace-only\",\n \"allowed_mounts\": []\n },\n \"supported\": true,\n \"active\": true,\n \"namespace_supported\": true,\n \"namespace_active\": true,\n \"network_supported\": true,\n \"network_active\": false,\n \"filesystem_mode\": \"workspace-only\",\n \"filesystem_active\": true,\n \"allowed_mounts\": [],\n \"in_container\": false,\n \"container_markers\": [],\n \"fallback_reason\": null\n }\n}","tool_name":"bash","tool_use_id":"toolu_01EuTzVfUK7iPRBvjZAovzfV","type":"tool_result"}],"role":"tool"},{"blocks":[{"text":"Bash tool is working perfectly! ✅\n\nHere's a quick summary:\n- **Status**: Operational 🎉\n- **Date**: Wed Apr 1, 2026\n- **Working directory**: `/home/bellman/Workspace/clawd-code/rust`\n- **Sandbox**: Enabled and active\n\nHow can I help you today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4746,"output_tokens":84}}],"version":1}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"messages":[{"blocks":[{"text":"who are you?","type":"text"}],"role":"user"},{"blocks":[{"text":"I'm **Claude**, an AI assistant built by Anthropic, currently running as a **Claude Code agent** (Claude Opus 4.6). I'm set up in your workspace at `/home/bellman/Workspace/clawd-code/rust` with the **oh-my-claudecode (OMC)** orchestration layer active.\n\nHere's what I can help you with:\n\n- **Software engineering tasks** — reading, writing, and refactoring code\n- **Debugging** — diagnosing issues, running tests, tracing errors\n- **Research & planning** — exploring codebases, understanding architecture, planning changes\n- **Shell commands** — building, testing, running tools in your environment\n- **Multi-agent workflows** — delegating specialized work (reviews, deep analysis, etc.) via OMC\n\nI have access to your local filesystem, can execute shell commands, search the web, and coordinate specialized sub-agents for complex tasks.\n\nWhat can I help you with today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4292,"output_tokens":220}}],"version":1}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"messages":[],"version":1}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"messages":[],"version":1}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,22 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"content": "Phase 0: Structural Cleanup — spawn 4 agents for 0.1-0.4",
|
|
||||||
"activeForm": "Executing Phase 0: Structural Cleanup via sub-agents",
|
|
||||||
"status": "in_progress"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"content": "Phase 1.1-1.2: Status bar with live HUD and token counter",
|
|
||||||
"activeForm": "Awaiting Phase 0",
|
|
||||||
"status": "pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"content": "Phase 2.4: Remove artificial 8ms stream delay",
|
|
||||||
"activeForm": "Awaiting Phase 0",
|
|
||||||
"status": "pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"content": "Phase 3.1: Collapsible tool output",
|
|
||||||
"activeForm": "Awaiting Phase 0",
|
|
||||||
"status": "pending"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
# TUI Enhancement Plan — Claw Code (`rusty-claude-cli`)
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
This plan covers a comprehensive analysis of the current terminal user interface and proposes phased enhancements that will transform the existing REPL/prompt CLI into a polished, modern TUI experience — while preserving the existing clean architecture and test coverage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Current Architecture Analysis
|
|
||||||
|
|
||||||
### Crate Map
|
|
||||||
|
|
||||||
| Crate | Purpose | Lines | TUI Relevance |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `rusty-claude-cli` | Main binary: REPL loop, arg parsing, rendering, API bridge | ~3,600 | **Primary TUI surface** |
|
|
||||||
| `runtime` | Session, conversation loop, config, permissions, compaction | ~5,300 | Provides data/state |
|
|
||||||
| `api` | Anthropic HTTP client + SSE streaming | ~1,500 | Provides stream events |
|
|
||||||
| `commands` | Slash command metadata/parsing/help | ~470 | Drives command dispatch |
|
|
||||||
| `tools` | 18 built-in tool implementations | ~3,500 | Tool execution display |
|
|
||||||
|
|
||||||
### Current TUI Components
|
|
||||||
|
|
||||||
| Component | File | What It Does Today | Quality |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
|
|
||||||
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
|
|
||||||
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
|
|
||||||
| **Alt App** | `app.rs` (398 lines) | An earlier `CliApp` prototype with `ConversationClient`, stream event handling, `TerminalRenderer`, output format support | ⚠️ Appears unused/legacy |
|
|
||||||
|
|
||||||
### Key Dependencies
|
|
||||||
|
|
||||||
- **crossterm 0.28** — terminal control (cursor, colors, clear)
|
|
||||||
- **pulldown-cmark 0.13** — Markdown parsing
|
|
||||||
- **syntect 5** — syntax highlighting
|
|
||||||
- **rustyline 15** — line editing with completion
|
|
||||||
- **serde_json** — tool I/O formatting
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
|
|
||||||
1. **Clean rendering pipeline**: Markdown rendering is well-structured with state tracking, table rendering, code highlighting
|
|
||||||
2. **Rich tool display**: Tool calls get box-drawing borders (`╭─ name ─╮`), results show ✓/✗ icons
|
|
||||||
3. **Comprehensive slash commands**: 15 commands covering model switching, permissions, sessions, config, diff, export
|
|
||||||
4. **Session management**: Full persistence, resume, list, switch, compaction
|
|
||||||
5. **Permission prompting**: Interactive Y/N approval for restricted tool calls
|
|
||||||
6. **Thorough tests**: Every formatting function, every parse path has unit tests
|
|
||||||
|
|
||||||
### Weaknesses & Gaps
|
|
||||||
|
|
||||||
1. **`main.rs` is a 3,159-line monolith** — all REPL logic, formatting, API bridging, session management, and tests in one file
|
|
||||||
2. **No alternate-screen / full-screen layout** — everything is inline scrolling output
|
|
||||||
3. **No progress bars** — only a single braille spinner; no indication of streaming progress or token counts during generation
|
|
||||||
4. **No visual diff rendering** — `/diff` just dumps raw git diff text
|
|
||||||
5. **No syntax highlighting in streamed output** — markdown rendering only applies to tool results, not to the main assistant response stream
|
|
||||||
6. **No status bar / HUD** — model, tokens, session info not visible during interaction
|
|
||||||
7. **No image/attachment preview** — `SendUserMessage` resolves attachments but never displays them
|
|
||||||
8. **Streaming is char-by-char with artificial delay** — `stream_markdown` sleeps 8ms per whitespace-delimited chunk
|
|
||||||
9. **No color theme customization** — hardcoded `ColorTheme::default()`
|
|
||||||
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
|
|
||||||
11. **Dual app structs** — `app.rs` has a separate `CliApp` that duplicates `LiveCli` from `main.rs`
|
|
||||||
12. **No pager for long outputs** — `/status`, `/config`, `/memory` can overflow the viewport
|
|
||||||
13. **Tool results not collapsible** — large bash outputs flood the screen
|
|
||||||
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
|
|
||||||
15. **No auto-complete for tool arguments** — only slash command names complete
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Enhancement Plan
|
|
||||||
|
|
||||||
### Phase 0: Structural Cleanup (Foundation)
|
|
||||||
|
|
||||||
**Goal**: Break the monolith, remove dead code, establish the module structure for TUI work.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
|
|
||||||
| 0.2 | **Remove or merge the legacy `CliApp`** — The existing `app.rs` has an unused `CliApp` with its own `ConversationClient`-based rendering. Either delete it or merge its unique features (stream event handler pattern) into the active `LiveCli` | S |
|
|
||||||
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is a hand-rolled parser that duplicates the clap-based `args.rs`. Consolidate on the hand-rolled parser (it's more feature-complete) and move it to `args.rs`, or adopt clap fully | S |
|
|
||||||
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
|
|
||||||
|
|
||||||
### Phase 1: Status Bar & Live HUD
|
|
||||||
|
|
||||||
**Goal**: Persistent information display during interaction.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 1.1 | **Terminal-size-aware status line** — Use `crossterm::terminal::size()` to render a bottom-pinned status bar showing: model name, permission mode, session ID, cumulative token count, estimated cost | M |
|
|
||||||
| 1.2 | **Live token counter** — Update the status bar in real-time as `AssistantEvent::Usage` and `AssistantEvent::TextDelta` events arrive during streaming | M |
|
|
||||||
| 1.3 | **Turn duration timer** — Show elapsed time for the current turn (the `showTurnDuration` config already exists in Config tool but isn't wired up) | S |
|
|
||||||
| 1.4 | **Git branch indicator** — Display the current git branch in the status bar (already parsed via `parse_git_status_metadata`) | S |
|
|
||||||
|
|
||||||
### Phase 2: Enhanced Streaming Output
|
|
||||||
|
|
||||||
**Goal**: Make the main response stream visually rich and responsive.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 2.1 | **Live markdown rendering** — Instead of raw text streaming, buffer text deltas and incrementally render Markdown as it arrives (heading detection, bold/italic, inline code). The existing `TerminalRenderer::render_markdown` can be adapted for incremental use | L |
|
|
||||||
| 2.2 | **Thinking indicator** — When extended thinking/reasoning is active, show a distinct animated indicator (e.g., `🧠 Reasoning...` with pulsing dots or a different spinner) instead of the generic `🦀 Thinking...` | S |
|
|
||||||
| 2.3 | **Streaming progress bar** — Add an optional horizontal progress indicator below the spinner showing approximate completion (based on max_tokens vs. output_tokens so far) | M |
|
|
||||||
| 2.4 | **Remove artificial stream delay** — The current `stream_markdown` sleeps 8ms per chunk. For tool results this is fine, but for the main response stream it should be immediate or configurable | S |
|
|
||||||
|
|
||||||
### Phase 3: Tool Call Visualization
|
|
||||||
|
|
||||||
**Goal**: Make tool execution legible and navigable.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 3.1 | **Collapsible tool output** — For tool results longer than N lines (configurable, default 15), show a summary with `[+] Expand` hint; pressing a key reveals the full output. Initially implement as truncation with a "full output saved to file" fallback | M |
|
|
||||||
| 3.2 | **Syntax-highlighted tool results** — When tool results contain code (detected by tool name — `bash` stdout, `read_file` content, `REPL` output), apply syntect highlighting rather than rendering as plain text | M |
|
|
||||||
| 3.3 | **Tool call timeline** — For multi-tool turns, show a compact summary: `🔧 bash → ✓ | read_file → ✓ | edit_file → ✓ (3 tools, 1.2s)` after all tool calls complete | S |
|
|
||||||
| 3.4 | **Diff-aware edit_file display** — When `edit_file` succeeds, show a colored unified diff of the change instead of just `✓ edit_file: path` | M |
|
|
||||||
| 3.5 | **Permission prompt enhancement** — Style the approval prompt with box drawing, color the tool name, show a one-line summary of what the tool will do | S |
|
|
||||||
|
|
||||||
### Phase 4: Enhanced Slash Commands & Navigation
|
|
||||||
|
|
||||||
**Goal**: Improve information display and add missing features.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 4.1 | **Colored `/diff` output** — Parse the git diff and render it with red/green coloring for removals/additions, similar to `delta` or `diff-so-fancy` | M |
|
|
||||||
| 4.2 | **Pager for long outputs** — When `/status`, `/config`, `/memory`, or `/diff` produce output longer than the terminal height, pipe through an internal pager (scroll with j/k/q) or external `$PAGER` | M |
|
|
||||||
| 4.3 | **`/search` command** — Add a new command to search conversation history by keyword | M |
|
|
||||||
| 4.4 | **`/undo` command** — Undo the last file edit by restoring from the `originalFile` data in `write_file`/`edit_file` tool results | M |
|
|
||||||
| 4.5 | **Interactive session picker** — Replace the text-based `/session list` with an interactive fuzzy-filterable list (up/down arrows to select, enter to switch) | L |
|
|
||||||
| 4.6 | **Tab completion for tool arguments** — Extend `SlashCommandHelper` to complete file paths after `/export`, model names after `/model`, session IDs after `/session switch` | M |
|
|
||||||
|
|
||||||
### Phase 5: Color Themes & Configuration
|
|
||||||
|
|
||||||
**Goal**: User-customizable visual appearance.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 5.1 | **Named color themes** — Add `dark` (current default), `light`, `solarized`, `catppuccin` themes. Wire to the existing `Config` tool's `theme` setting | M |
|
|
||||||
| 5.2 | **ANSI-256 / truecolor detection** — Detect terminal capabilities and fall back gracefully (no colors → 16 colors → 256 → truecolor) | M |
|
|
||||||
| 5.3 | **Configurable spinner style** — Allow choosing between braille dots, bar, moon phases, etc. | S |
|
|
||||||
| 5.4 | **Banner customization** — Make the ASCII art banner optional or configurable via settings | S |
|
|
||||||
|
|
||||||
### Phase 6: Full-Screen TUI Mode (Stretch)
|
|
||||||
|
|
||||||
**Goal**: Optional alternate-screen layout for power users.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 6.1 | **Add `ratatui` dependency** — Introduce `ratatui` (terminal UI framework) as an optional dependency for the full-screen mode | S |
|
|
||||||
| 6.2 | **Split-pane layout** — Top pane: conversation with scrollback; Bottom pane: input area; Right sidebar (optional): tool status/todo list | XL |
|
|
||||||
| 6.3 | **Scrollable conversation view** — Navigate past messages with PgUp/PgDn, search within conversation | L |
|
|
||||||
| 6.4 | **Keyboard shortcuts panel** — Show `?` help overlay with all keybindings | M |
|
|
||||||
| 6.5 | **Mouse support** — Click to expand tool results, scroll conversation, select text for copy | L |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Priority Recommendation
|
|
||||||
|
|
||||||
### Immediate (High Impact, Moderate Effort)
|
|
||||||
|
|
||||||
1. **Phase 0** — Essential cleanup. The 3,159-line `main.rs` is the #1 maintenance risk and blocks clean TUI additions.
|
|
||||||
2. **Phase 1.1–1.2** — Status bar with live tokens. Highest-impact UX win: users constantly want to know token usage.
|
|
||||||
3. **Phase 2.4** — Remove artificial delay. Low effort, immediately noticeable improvement.
|
|
||||||
4. **Phase 3.1** — Collapsible tool output. Large bash outputs currently wreck readability.
|
|
||||||
|
|
||||||
### Near-Term (Next Sprint)
|
|
||||||
|
|
||||||
5. **Phase 2.1** — Live markdown rendering. Makes the core interaction feel polished.
|
|
||||||
6. **Phase 3.2** — Syntax-highlighted tool results.
|
|
||||||
7. **Phase 3.4** — Diff-aware edit display.
|
|
||||||
8. **Phase 4.1** — Colored diff for `/diff`.
|
|
||||||
|
|
||||||
### Longer-Term
|
|
||||||
|
|
||||||
9. **Phase 5** — Color themes (user demand-driven).
|
|
||||||
10. **Phase 4.2–4.6** — Enhanced navigation and commands.
|
|
||||||
11. **Phase 6** — Full-screen mode (major undertaking, evaluate after earlier phases ship).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Architecture Recommendations
|
|
||||||
|
|
||||||
### Module Structure After Phase 0
|
|
||||||
|
|
||||||
```
|
|
||||||
crates/rusty-claude-cli/src/
|
|
||||||
├── main.rs # Entrypoint, arg dispatch only (~100 lines)
|
|
||||||
├── args.rs # CLI argument parsing (consolidate existing two parsers)
|
|
||||||
├── app.rs # LiveCli struct, REPL loop, turn execution
|
|
||||||
├── format.rs # All report formatting (status, cost, model, permissions, etc.)
|
|
||||||
├── session_mgr.rs # Session CRUD: create, resume, list, switch, persist
|
|
||||||
├── init.rs # Repo initialization (unchanged)
|
|
||||||
├── input.rs # Line editor (unchanged, minor extensions)
|
|
||||||
├── render.rs # TerminalRenderer, Spinner (extended)
|
|
||||||
└── tui/
|
|
||||||
├── mod.rs # TUI module root
|
|
||||||
├── status_bar.rs # Persistent bottom status line
|
|
||||||
├── tool_panel.rs # Tool call visualization (boxes, timelines, collapsible)
|
|
||||||
├── diff_view.rs # Colored diff rendering
|
|
||||||
├── pager.rs # Internal pager for long outputs
|
|
||||||
└── theme.rs # Color theme definitions and selection
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Design Principles
|
|
||||||
|
|
||||||
1. **Keep the inline REPL as the default** — Full-screen TUI should be opt-in (`--tui` flag)
|
|
||||||
2. **Everything testable without a terminal** — All formatting functions take `&mut impl Write`, never assume stdout directly
|
|
||||||
3. **Streaming-first** — Rendering should work incrementally, not buffering the entire response
|
|
||||||
4. **Respect `crossterm` for all terminal control** — Don't mix raw ANSI escape codes with crossterm (the current codebase does this in the startup banner)
|
|
||||||
5. **Feature-gate heavy dependencies** — `ratatui` should be behind a `full-tui` feature flag
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Risk Assessment
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
|
||||||
|---|---|
|
|
||||||
| Breaking the working REPL during refactor | Phase 0 is pure restructuring with existing test coverage as safety net |
|
|
||||||
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
|
|
||||||
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
|
|
||||||
| Scope creep into Phase 6 | Ship Phases 0–3 as a coherent release before starting Phase 6 |
|
|
||||||
| `app.rs` vs `main.rs` confusion | Phase 0.2 explicitly resolves this by removing the legacy `CliApp` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Generated: 2026-03-31 | Workspace: `rust/` | Branch: `dev/rust`*
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version = "12"
|
|
||||||
|
|
||||||
[overrides]
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
# TUI Enhancement Plan — Claw Code (`rusty-claude-cli`)
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
This plan covers a comprehensive analysis of the current terminal user interface and proposes phased enhancements that will transform the existing REPL/prompt CLI into a polished, modern TUI experience — while preserving the existing clean architecture and test coverage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Current Architecture Analysis
|
|
||||||
|
|
||||||
### Crate Map
|
|
||||||
|
|
||||||
| Crate | Purpose | Lines | TUI Relevance |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `rusty-claude-cli` | Main binary: REPL loop, arg parsing, rendering, API bridge | ~3,600 | **Primary TUI surface** |
|
|
||||||
| `runtime` | Session, conversation loop, config, permissions, compaction | ~5,300 | Provides data/state |
|
|
||||||
| `api` | Anthropic HTTP client + SSE streaming | ~1,500 | Provides stream events |
|
|
||||||
| `commands` | Slash command metadata/parsing/help | ~470 | Drives command dispatch |
|
|
||||||
| `tools` | 18 built-in tool implementations | ~3,500 | Tool execution display |
|
|
||||||
|
|
||||||
### Current TUI Components
|
|
||||||
|
|
||||||
| Component | File | What It Does Today | Quality |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
|
|
||||||
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
|
|
||||||
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
|
|
||||||
| **Alt App** | `app.rs` (398 lines) | An earlier `CliApp` prototype with `ConversationClient`, stream event handling, `TerminalRenderer`, output format support | ⚠️ Appears unused/legacy |
|
|
||||||
|
|
||||||
### Key Dependencies
|
|
||||||
|
|
||||||
- **crossterm 0.28** — terminal control (cursor, colors, clear)
|
|
||||||
- **pulldown-cmark 0.13** — Markdown parsing
|
|
||||||
- **syntect 5** — syntax highlighting
|
|
||||||
- **rustyline 15** — line editing with completion
|
|
||||||
- **serde_json** — tool I/O formatting
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
|
|
||||||
1. **Clean rendering pipeline**: Markdown rendering is well-structured with state tracking, table rendering, code highlighting
|
|
||||||
2. **Rich tool display**: Tool calls get box-drawing borders (`╭─ name ─╮`), results show ✓/✗ icons
|
|
||||||
3. **Comprehensive slash commands**: 15 commands covering model switching, permissions, sessions, config, diff, export
|
|
||||||
4. **Session management**: Full persistence, resume, list, switch, compaction
|
|
||||||
5. **Permission prompting**: Interactive Y/N approval for restricted tool calls
|
|
||||||
6. **Thorough tests**: Every formatting function, every parse path has unit tests
|
|
||||||
|
|
||||||
### Weaknesses & Gaps
|
|
||||||
|
|
||||||
1. **`main.rs` is a 3,159-line monolith** — all REPL logic, formatting, API bridging, session management, and tests in one file
|
|
||||||
2. **No alternate-screen / full-screen layout** — everything is inline scrolling output
|
|
||||||
3. **No progress bars** — only a single braille spinner; no indication of streaming progress or token counts during generation
|
|
||||||
4. **No visual diff rendering** — `/diff` just dumps raw git diff text
|
|
||||||
5. **No syntax highlighting in streamed output** — markdown rendering only applies to tool results, not to the main assistant response stream
|
|
||||||
6. **No status bar / HUD** — model, tokens, session info not visible during interaction
|
|
||||||
7. **No image/attachment preview** — `SendUserMessage` resolves attachments but never displays them
|
|
||||||
8. **Streaming is char-by-char with artificial delay** — `stream_markdown` sleeps 8ms per whitespace-delimited chunk
|
|
||||||
9. **No color theme customization** — hardcoded `ColorTheme::default()`
|
|
||||||
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
|
|
||||||
11. **Dual app structs** — `app.rs` has a separate `CliApp` that duplicates `LiveCli` from `main.rs`
|
|
||||||
12. **No pager for long outputs** — `/status`, `/config`, `/memory` can overflow the viewport
|
|
||||||
13. **Tool results not collapsible** — large bash outputs flood the screen
|
|
||||||
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
|
|
||||||
15. **No auto-complete for tool arguments** — only slash command names complete
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Enhancement Plan
|
|
||||||
|
|
||||||
### Phase 0: Structural Cleanup (Foundation)
|
|
||||||
|
|
||||||
**Goal**: Break the monolith, remove dead code, establish the module structure for TUI work.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
|
|
||||||
| 0.2 | **Remove or merge the legacy `CliApp`** — The existing `app.rs` has an unused `CliApp` with its own `ConversationClient`-based rendering. Either delete it or merge its unique features (stream event handler pattern) into the active `LiveCli` | S |
|
|
||||||
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is a hand-rolled parser that duplicates the clap-based `args.rs`. Consolidate on the hand-rolled parser (it's more feature-complete) and move it to `args.rs`, or adopt clap fully | S |
|
|
||||||
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
|
|
||||||
|
|
||||||
### Phase 1: Status Bar & Live HUD
|
|
||||||
|
|
||||||
**Goal**: Persistent information display during interaction.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 1.1 | **Terminal-size-aware status line** — Use `crossterm::terminal::size()` to render a bottom-pinned status bar showing: model name, permission mode, session ID, cumulative token count, estimated cost | M |
|
|
||||||
| 1.2 | **Live token counter** — Update the status bar in real-time as `AssistantEvent::Usage` and `AssistantEvent::TextDelta` events arrive during streaming | M |
|
|
||||||
| 1.3 | **Turn duration timer** — Show elapsed time for the current turn (the `showTurnDuration` config already exists in Config tool but isn't wired up) | S |
|
|
||||||
| 1.4 | **Git branch indicator** — Display the current git branch in the status bar (already parsed via `parse_git_status_metadata`) | S |
|
|
||||||
|
|
||||||
### Phase 2: Enhanced Streaming Output
|
|
||||||
|
|
||||||
**Goal**: Make the main response stream visually rich and responsive.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 2.1 | **Live markdown rendering** — Instead of raw text streaming, buffer text deltas and incrementally render Markdown as it arrives (heading detection, bold/italic, inline code). The existing `TerminalRenderer::render_markdown` can be adapted for incremental use | L |
|
|
||||||
| 2.2 | **Thinking indicator** — When extended thinking/reasoning is active, show a distinct animated indicator (e.g., `🧠 Reasoning...` with pulsing dots or a different spinner) instead of the generic `🦀 Thinking...` | S |
|
|
||||||
| 2.3 | **Streaming progress bar** — Add an optional horizontal progress indicator below the spinner showing approximate completion (based on max_tokens vs. output_tokens so far) | M |
|
|
||||||
| 2.4 | **Remove artificial stream delay** — The current `stream_markdown` sleeps 8ms per chunk. For tool results this is fine, but for the main response stream it should be immediate or configurable | S |
|
|
||||||
|
|
||||||
### Phase 3: Tool Call Visualization
|
|
||||||
|
|
||||||
**Goal**: Make tool execution legible and navigable.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 3.1 | **Collapsible tool output** — For tool results longer than N lines (configurable, default 15), show a summary with `[+] Expand` hint; pressing a key reveals the full output. Initially implement as truncation with a "full output saved to file" fallback | M |
|
|
||||||
| 3.2 | **Syntax-highlighted tool results** — When tool results contain code (detected by tool name — `bash` stdout, `read_file` content, `REPL` output), apply syntect highlighting rather than rendering as plain text | M |
|
|
||||||
| 3.3 | **Tool call timeline** — For multi-tool turns, show a compact summary: `🔧 bash → ✓ | read_file → ✓ | edit_file → ✓ (3 tools, 1.2s)` after all tool calls complete | S |
|
|
||||||
| 3.4 | **Diff-aware edit_file display** — When `edit_file` succeeds, show a colored unified diff of the change instead of just `✓ edit_file: path` | M |
|
|
||||||
| 3.5 | **Permission prompt enhancement** — Style the approval prompt with box drawing, color the tool name, show a one-line summary of what the tool will do | S |
|
|
||||||
|
|
||||||
### Phase 4: Enhanced Slash Commands & Navigation
|
|
||||||
|
|
||||||
**Goal**: Improve information display and add missing features.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 4.1 | **Colored `/diff` output** — Parse the git diff and render it with red/green coloring for removals/additions, similar to `delta` or `diff-so-fancy` | M |
|
|
||||||
| 4.2 | **Pager for long outputs** — When `/status`, `/config`, `/memory`, or `/diff` produce output longer than the terminal height, pipe through an internal pager (scroll with j/k/q) or external `$PAGER` | M |
|
|
||||||
| 4.3 | **`/search` command** — Add a new command to search conversation history by keyword | M |
|
|
||||||
| 4.4 | **`/undo` command** — Undo the last file edit by restoring from the `originalFile` data in `write_file`/`edit_file` tool results | M |
|
|
||||||
| 4.5 | **Interactive session picker** — Replace the text-based `/session list` with an interactive fuzzy-filterable list (up/down arrows to select, enter to switch) | L |
|
|
||||||
| 4.6 | **Tab completion for tool arguments** — Extend `SlashCommandHelper` to complete file paths after `/export`, model names after `/model`, session IDs after `/session switch` | M |
|
|
||||||
|
|
||||||
### Phase 5: Color Themes & Configuration
|
|
||||||
|
|
||||||
**Goal**: User-customizable visual appearance.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 5.1 | **Named color themes** — Add `dark` (current default), `light`, `solarized`, `catppuccin` themes. Wire to the existing `Config` tool's `theme` setting | M |
|
|
||||||
| 5.2 | **ANSI-256 / truecolor detection** — Detect terminal capabilities and fall back gracefully (no colors → 16 colors → 256 → truecolor) | M |
|
|
||||||
| 5.3 | **Configurable spinner style** — Allow choosing between braille dots, bar, moon phases, etc. | S |
|
|
||||||
| 5.4 | **Banner customization** — Make the ASCII art banner optional or configurable via settings | S |
|
|
||||||
|
|
||||||
### Phase 6: Full-Screen TUI Mode (Stretch)
|
|
||||||
|
|
||||||
**Goal**: Optional alternate-screen layout for power users.
|
|
||||||
|
|
||||||
| Task | Description | Effort |
|
|
||||||
|---|---|---|
|
|
||||||
| 6.1 | **Add `ratatui` dependency** — Introduce `ratatui` (terminal UI framework) as an optional dependency for the full-screen mode | S |
|
|
||||||
| 6.2 | **Split-pane layout** — Top pane: conversation with scrollback; Bottom pane: input area; Right sidebar (optional): tool status/todo list | XL |
|
|
||||||
| 6.3 | **Scrollable conversation view** — Navigate past messages with PgUp/PgDn, search within conversation | L |
|
|
||||||
| 6.4 | **Keyboard shortcuts panel** — Show `?` help overlay with all keybindings | M |
|
|
||||||
| 6.5 | **Mouse support** — Click to expand tool results, scroll conversation, select text for copy | L |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Priority Recommendation
|
|
||||||
|
|
||||||
### Immediate (High Impact, Moderate Effort)
|
|
||||||
|
|
||||||
1. **Phase 0** — Essential cleanup. The 3,159-line `main.rs` is the #1 maintenance risk and blocks clean TUI additions.
|
|
||||||
2. **Phase 1.1–1.2** — Status bar with live tokens. Highest-impact UX win: users constantly want to know token usage.
|
|
||||||
3. **Phase 2.4** — Remove artificial delay. Low effort, immediately noticeable improvement.
|
|
||||||
4. **Phase 3.1** — Collapsible tool output. Large bash outputs currently wreck readability.
|
|
||||||
|
|
||||||
### Near-Term (Next Sprint)
|
|
||||||
|
|
||||||
5. **Phase 2.1** — Live markdown rendering. Makes the core interaction feel polished.
|
|
||||||
6. **Phase 3.2** — Syntax-highlighted tool results.
|
|
||||||
7. **Phase 3.4** — Diff-aware edit display.
|
|
||||||
8. **Phase 4.1** — Colored diff for `/diff`.
|
|
||||||
|
|
||||||
### Longer-Term
|
|
||||||
|
|
||||||
9. **Phase 5** — Color themes (user demand-driven).
|
|
||||||
10. **Phase 4.2–4.6** — Enhanced navigation and commands.
|
|
||||||
11. **Phase 6** — Full-screen mode (major undertaking, evaluate after earlier phases ship).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Architecture Recommendations
|
|
||||||
|
|
||||||
### Module Structure After Phase 0
|
|
||||||
|
|
||||||
```
|
|
||||||
crates/rusty-claude-cli/src/
|
|
||||||
├── main.rs # Entrypoint, arg dispatch only (~100 lines)
|
|
||||||
├── args.rs # CLI argument parsing (consolidate existing two parsers)
|
|
||||||
├── app.rs # LiveCli struct, REPL loop, turn execution
|
|
||||||
├── format.rs # All report formatting (status, cost, model, permissions, etc.)
|
|
||||||
├── session_mgr.rs # Session CRUD: create, resume, list, switch, persist
|
|
||||||
├── init.rs # Repo initialization (unchanged)
|
|
||||||
├── input.rs # Line editor (unchanged, minor extensions)
|
|
||||||
├── render.rs # TerminalRenderer, Spinner (extended)
|
|
||||||
└── tui/
|
|
||||||
├── mod.rs # TUI module root
|
|
||||||
├── status_bar.rs # Persistent bottom status line
|
|
||||||
├── tool_panel.rs # Tool call visualization (boxes, timelines, collapsible)
|
|
||||||
├── diff_view.rs # Colored diff rendering
|
|
||||||
├── pager.rs # Internal pager for long outputs
|
|
||||||
└── theme.rs # Color theme definitions and selection
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Design Principles
|
|
||||||
|
|
||||||
1. **Keep the inline REPL as the default** — Full-screen TUI should be opt-in (`--tui` flag)
|
|
||||||
2. **Everything testable without a terminal** — All formatting functions take `&mut impl Write`, never assume stdout directly
|
|
||||||
3. **Streaming-first** — Rendering should work incrementally, not buffering the entire response
|
|
||||||
4. **Respect `crossterm` for all terminal control** — Don't mix raw ANSI escape codes with crossterm (the current codebase does this in the startup banner)
|
|
||||||
5. **Feature-gate heavy dependencies** — `ratatui` should be behind a `full-tui` feature flag
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Risk Assessment
|
|
||||||
|
|
||||||
| Risk | Mitigation |
|
|
||||||
|---|---|
|
|
||||||
| Breaking the working REPL during refactor | Phase 0 is pure restructuring with existing test coverage as safety net |
|
|
||||||
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
|
|
||||||
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
|
|
||||||
| Scope creep into Phase 6 | Ship Phases 0–3 as a coherent release before starting Phase 6 |
|
|
||||||
| `app.rs` vs `main.rs` confusion | Phase 0.2 explicitly resolves this by removing the legacy `CliApp` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Generated: 2026-03-31 | Workspace: `rust/` | Branch: `dev/rust`*
|
|
||||||
@@ -4,8 +4,8 @@ mod sse;
|
|||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub use client::{
|
pub use client::{
|
||||||
oauth_token_is_expired, read_base_url, resolve_saved_oauth_token,
|
oauth_token_is_expired, read_base_url, resolve_saved_oauth_token, resolve_startup_auth_source,
|
||||||
resolve_startup_auth_source, AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
|
AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
|
||||||
};
|
};
|
||||||
pub use error::ApiError;
|
pub use error::ApiError;
|
||||||
pub use sse::{parse_frame, SseParser};
|
pub use sse::{parse_frame, SseParser};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use commands::{
|
|||||||
};
|
};
|
||||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||||
use init::initialize_repo;
|
use init::initialize_repo;
|
||||||
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
use render::{Spinner, TerminalRenderer};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
|
||||||
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
|
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
|
||||||
@@ -2011,8 +2011,6 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
} else {
|
} else {
|
||||||
&mut sink
|
&mut sink
|
||||||
};
|
};
|
||||||
let renderer = TerminalRenderer::new();
|
|
||||||
let mut markdown_stream = MarkdownStreamState::default();
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut pending_tool: Option<(String, String, String)> = None;
|
let mut pending_tool: Option<(String, String, String)> = None;
|
||||||
let mut saw_stop = false;
|
let mut saw_stop = false;
|
||||||
@@ -2040,11 +2038,9 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
||||||
ContentBlockDelta::TextDelta { text } => {
|
ContentBlockDelta::TextDelta { text } => {
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
if let Some(rendered) = markdown_stream.push(&renderer, &text) {
|
write!(out, "{text}")
|
||||||
write!(out, "{rendered}")
|
.and_then(|()| out.flush())
|
||||||
.and_then(|()| out.flush())
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
|
||||||
}
|
|
||||||
events.push(AssistantEvent::TextDelta(text));
|
events.push(AssistantEvent::TextDelta(text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2055,11 +2051,6 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
ApiStreamEvent::ContentBlockStop(_) => {
|
ApiStreamEvent::ContentBlockStop(_) => {
|
||||||
if let Some(rendered) = markdown_stream.flush(&renderer) {
|
|
||||||
write!(out, "{rendered}")
|
|
||||||
.and_then(|()| out.flush())
|
|
||||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
|
||||||
}
|
|
||||||
if let Some((id, name, input)) = pending_tool.take() {
|
if let Some((id, name, input)) = pending_tool.take() {
|
||||||
// Display tool call now that input is fully accumulated
|
// Display tool call now that input is fully accumulated
|
||||||
writeln!(out, "\n{}", format_tool_call_start(&name, &input))
|
writeln!(out, "\n{}", format_tool_call_start(&name, &input))
|
||||||
@@ -2078,11 +2069,6 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
}
|
}
|
||||||
ApiStreamEvent::MessageStop(_) => {
|
ApiStreamEvent::MessageStop(_) => {
|
||||||
saw_stop = true;
|
saw_stop = true;
|
||||||
if let Some(rendered) = markdown_stream.flush(&renderer) {
|
|
||||||
write!(out, "{rendered}")
|
|
||||||
.and_then(|()| out.flush())
|
|
||||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
|
||||||
}
|
|
||||||
events.push(AssistantEvent::MessageStop);
|
events.push(AssistantEvent::MessageStop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2185,49 +2171,56 @@ fn format_tool_call_start(name: &str, input: &str) -> String {
|
|||||||
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
|
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
|
||||||
|
|
||||||
let detail = match name {
|
let detail = match name {
|
||||||
"bash" | "Bash" => format_bash_call(&parsed),
|
"bash" | "Bash" => parsed
|
||||||
"read_file" | "Read" => {
|
.get("command")
|
||||||
let path = extract_tool_path(&parsed);
|
.and_then(|v| v.as_str())
|
||||||
format!("\x1b[2m📄 Reading {path}…\x1b[0m")
|
.map(|cmd| truncate_for_summary(cmd, 120))
|
||||||
}
|
.unwrap_or_default(),
|
||||||
|
"read_file" | "Read" => parsed
|
||||||
|
.get("file_path")
|
||||||
|
.or_else(|| parsed.get("path"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("?")
|
||||||
|
.to_string(),
|
||||||
"write_file" | "Write" => {
|
"write_file" | "Write" => {
|
||||||
let path = extract_tool_path(&parsed);
|
let path = parsed
|
||||||
|
.get("file_path")
|
||||||
|
.or_else(|| parsed.get("path"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("?");
|
||||||
let lines = parsed
|
let lines = parsed
|
||||||
.get("content")
|
.get("content")
|
||||||
.and_then(|value| value.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map_or(0, |content| content.lines().count());
|
.map_or(0, |c| c.lines().count());
|
||||||
format!("\x1b[1;32m✏️ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m")
|
format!("{path} ({lines} lines)")
|
||||||
}
|
}
|
||||||
"edit_file" | "Edit" => {
|
"edit_file" | "Edit" => {
|
||||||
let path = extract_tool_path(&parsed);
|
let path = parsed
|
||||||
let old_value = parsed
|
.get("file_path")
|
||||||
.get("old_string")
|
.or_else(|| parsed.get("path"))
|
||||||
.or_else(|| parsed.get("oldString"))
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|value| value.as_str())
|
.unwrap_or("?");
|
||||||
.unwrap_or_default();
|
path.to_string()
|
||||||
let new_value = parsed
|
|
||||||
.get("new_string")
|
|
||||||
.or_else(|| parsed.get("newString"))
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
format!(
|
|
||||||
"\x1b[1;33m📝 Editing {path}\x1b[0m{}",
|
|
||||||
format_patch_preview(old_value, new_value)
|
|
||||||
.map(|preview| format!("\n{preview}"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
"glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed),
|
"glob_search" | "Glob" => parsed
|
||||||
"grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed),
|
.get("pattern")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("?")
|
||||||
|
.to_string(),
|
||||||
|
"grep_search" | "Grep" => parsed
|
||||||
|
.get("pattern")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("?")
|
||||||
|
.to_string(),
|
||||||
"web_search" | "WebSearch" => parsed
|
"web_search" | "WebSearch" => parsed
|
||||||
.get("query")
|
.get("query")
|
||||||
.and_then(|value| value.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("?")
|
.unwrap_or("?")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
_ => summarize_tool_payload(input),
|
_ => summarize_tool_payload(input),
|
||||||
};
|
};
|
||||||
|
|
||||||
let border = "─".repeat(name.len() + 8);
|
let border = "─".repeat(name.len() + 6);
|
||||||
format!(
|
format!(
|
||||||
"\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}╯\x1b[0m"
|
"\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}╯\x1b[0m"
|
||||||
)
|
)
|
||||||
@@ -2239,269 +2232,8 @@ fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
|
|||||||
} else {
|
} else {
|
||||||
"\x1b[1;32m✓\x1b[0m"
|
"\x1b[1;32m✓\x1b[0m"
|
||||||
};
|
};
|
||||||
if is_error {
|
let summary = truncate_for_summary(output.trim(), 200);
|
||||||
let summary = truncate_for_summary(output.trim(), 160);
|
format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {summary}")
|
||||||
return if summary.is_empty() {
|
|
||||||
format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
|
|
||||||
} else {
|
|
||||||
format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: serde_json::Value =
|
|
||||||
serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string()));
|
|
||||||
match name {
|
|
||||||
"bash" | "Bash" => format_bash_result(icon, &parsed),
|
|
||||||
"read_file" | "Read" => format_read_result(icon, &parsed),
|
|
||||||
"write_file" | "Write" => format_write_result(icon, &parsed),
|
|
||||||
"edit_file" | "Edit" => format_edit_result(icon, &parsed),
|
|
||||||
"glob_search" | "Glob" => format_glob_result(icon, &parsed),
|
|
||||||
"grep_search" | "Grep" => format_grep_result(icon, &parsed),
|
|
||||||
_ => {
|
|
||||||
let summary = truncate_for_summary(output.trim(), 200);
|
|
||||||
format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {summary}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_tool_path(parsed: &serde_json::Value) -> String {
|
|
||||||
parsed
|
|
||||||
.get("file_path")
|
|
||||||
.or_else(|| parsed.get("filePath"))
|
|
||||||
.or_else(|| parsed.get("path"))
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or("?")
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_search_start(label: &str, parsed: &serde_json::Value) -> String {
|
|
||||||
let pattern = parsed
|
|
||||||
.get("pattern")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or("?");
|
|
||||||
let scope = parsed
|
|
||||||
.get("path")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or(".");
|
|
||||||
format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_patch_preview(old_value: &str, new_value: &str) -> Option<String> {
|
|
||||||
if old_value.is_empty() && new_value.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(format!(
|
|
||||||
"\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m",
|
|
||||||
truncate_for_summary(first_visible_line(old_value), 72),
|
|
||||||
truncate_for_summary(first_visible_line(new_value), 72)
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_bash_call(parsed: &serde_json::Value) -> String {
|
|
||||||
let command = parsed
|
|
||||||
.get("command")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if command.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"\x1b[48;5;236;38;5;255m $ {} \x1b[0m",
|
|
||||||
truncate_for_summary(command, 160)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn first_visible_line(text: &str) -> &str {
|
|
||||||
text.lines()
|
|
||||||
.find(|line| !line.trim().is_empty())
|
|
||||||
.unwrap_or(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|
||||||
let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")];
|
|
||||||
if let Some(task_id) = parsed
|
|
||||||
.get("backgroundTaskId")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
{
|
|
||||||
lines[0].push_str(&format!(" backgrounded ({task_id})"));
|
|
||||||
} else if let Some(status) = parsed
|
|
||||||
.get("returnCodeInterpretation")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.filter(|status| !status.is_empty())
|
|
||||||
{
|
|
||||||
lines[0].push_str(&format!(" {status}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) {
|
|
||||||
if !stdout.trim().is_empty() {
|
|
||||||
lines.push(stdout.trim_end().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) {
|
|
||||||
if !stderr.trim().is_empty() {
|
|
||||||
lines.push(format!("\x1b[38;5;203m{}\x1b[0m", stderr.trim_end()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.join("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|
||||||
let file = parsed.get("file").unwrap_or(parsed);
|
|
||||||
let path = extract_tool_path(file);
|
|
||||||
let start_line = file
|
|
||||||
.get("startLine")
|
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
.unwrap_or(1);
|
|
||||||
let num_lines = file
|
|
||||||
.get("numLines")
|
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let total_lines = file
|
|
||||||
.get("totalLines")
|
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
.unwrap_or(num_lines);
|
|
||||||
let content = file
|
|
||||||
.get("content")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let end_line = start_line.saturating_add(num_lines.saturating_sub(1));
|
|
||||||
|
|
||||||
format!(
|
|
||||||
"{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}",
|
|
||||||
start_line,
|
|
||||||
end_line.max(start_line),
|
|
||||||
total_lines,
|
|
||||||
content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|
||||||
let path = extract_tool_path(parsed);
|
|
||||||
let kind = parsed
|
|
||||||
.get("type")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or("write");
|
|
||||||
let line_count = parsed
|
|
||||||
.get("content")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.map(|content| content.lines().count())
|
|
||||||
.unwrap_or(0);
|
|
||||||
format!(
|
|
||||||
"{icon} \x1b[1;32m✏️ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m",
|
|
||||||
if kind == "create" { "Wrote" } else { "Updated" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option<String> {
|
|
||||||
let hunks = parsed.get("structuredPatch")?.as_array()?;
|
|
||||||
let mut preview = Vec::new();
|
|
||||||
for hunk in hunks.iter().take(2) {
|
|
||||||
let lines = hunk.get("lines")?.as_array()?;
|
|
||||||
for line in lines.iter().filter_map(|value| value.as_str()).take(6) {
|
|
||||||
match line.chars().next() {
|
|
||||||
Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")),
|
|
||||||
Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")),
|
|
||||||
_ => preview.push(line.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if preview.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(preview.join("\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|
||||||
let path = extract_tool_path(parsed);
|
|
||||||
let suffix = if parsed
|
|
||||||
.get("replaceAll")
|
|
||||||
.and_then(|value| value.as_bool())
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
" (replace all)"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
let preview = format_structured_patch_preview(parsed).or_else(|| {
|
|
||||||
let old_value = parsed
|
|
||||||
.get("oldString")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let new_value = parsed
|
|
||||||
.get("newString")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
format_patch_preview(old_value, new_value)
|
|
||||||
});
|
|
||||||
|
|
||||||
match preview {
|
|
||||||
Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"),
|
|
||||||
None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|
||||||
let num_files = parsed
|
|
||||||
.get("numFiles")
|
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let filenames = parsed
|
|
||||||
.get("filenames")
|
|
||||||
.and_then(|value| value.as_array())
|
|
||||||
.map(|files| {
|
|
||||||
files
|
|
||||||
.iter()
|
|
||||||
.filter_map(|value| value.as_str())
|
|
||||||
.take(8)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
if filenames.is_empty() {
|
|
||||||
format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files")
|
|
||||||
} else {
|
|
||||||
format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String {
|
|
||||||
let num_matches = parsed
|
|
||||||
.get("numMatches")
|
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let num_files = parsed
|
|
||||||
.get("numFiles")
|
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let content = parsed
|
|
||||||
.get("content")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let filenames = parsed
|
|
||||||
.get("filenames")
|
|
||||||
.and_then(|value| value.as_array())
|
|
||||||
.map(|files| {
|
|
||||||
files
|
|
||||||
.iter()
|
|
||||||
.filter_map(|value| value.as_str())
|
|
||||||
.take(8)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
let summary = format!(
|
|
||||||
"{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files"
|
|
||||||
);
|
|
||||||
if !content.trim().is_empty() {
|
|
||||||
format!("{summary}\n{}", content.trim_end())
|
|
||||||
} else if !filenames.is_empty() {
|
|
||||||
format!("{summary}\n{filenames}")
|
|
||||||
} else {
|
|
||||||
summary
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn summarize_tool_payload(payload: &str) -> String {
|
fn summarize_tool_payload(payload: &str) -> String {
|
||||||
@@ -2532,8 +2264,7 @@ fn push_output_block(
|
|||||||
match block {
|
match block {
|
||||||
OutputContentBlock::Text { text } => {
|
OutputContentBlock::Text { text } => {
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
let rendered = TerminalRenderer::new().markdown_to_ansi(&text);
|
write!(out, "{text}")
|
||||||
write!(out, "{rendered}")
|
|
||||||
.and_then(|()| out.flush())
|
.and_then(|()| out.flush())
|
||||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||||
events.push(AssistantEvent::TextDelta(text));
|
events.push(AssistantEvent::TextDelta(text));
|
||||||
@@ -3325,35 +3056,9 @@ mod tests {
|
|||||||
assert!(start.contains("read_file"));
|
assert!(start.contains("read_file"));
|
||||||
assert!(start.contains("src/main.rs"));
|
assert!(start.contains("src/main.rs"));
|
||||||
|
|
||||||
let done = format_tool_result(
|
let done = format_tool_result("read_file", r#"{"contents":"hello"}"#, false);
|
||||||
"read_file",
|
assert!(done.contains("read_file:"));
|
||||||
r#"{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"#,
|
assert!(done.contains("contents"));
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert!(done.contains("📄 Read src/main.rs"));
|
|
||||||
assert!(done.contains("hello"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn push_output_block_renders_markdown_text() {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut events = Vec::new();
|
|
||||||
let mut pending_tool = None;
|
|
||||||
|
|
||||||
push_output_block(
|
|
||||||
OutputContentBlock::Text {
|
|
||||||
text: "# Heading".to_string(),
|
|
||||||
},
|
|
||||||
&mut out,
|
|
||||||
&mut events,
|
|
||||||
&mut pending_tool,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.expect("text block should render");
|
|
||||||
|
|
||||||
let rendered = String::from_utf8(out).expect("utf8");
|
|
||||||
assert!(rendered.contains("Heading"));
|
|
||||||
assert!(rendered.contains('\u{1b}'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use std::fmt::Write as FmtWrite;
|
use std::fmt::Write as FmtWrite;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition};
|
use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition};
|
||||||
use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize};
|
use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize};
|
||||||
@@ -20,7 +22,6 @@ pub struct ColorTheme {
|
|||||||
link: Color,
|
link: Color,
|
||||||
quote: Color,
|
quote: Color,
|
||||||
table_border: Color,
|
table_border: Color,
|
||||||
code_block_border: Color,
|
|
||||||
spinner_active: Color,
|
spinner_active: Color,
|
||||||
spinner_done: Color,
|
spinner_done: Color,
|
||||||
spinner_failed: Color,
|
spinner_failed: Color,
|
||||||
@@ -36,7 +37,6 @@ impl Default for ColorTheme {
|
|||||||
link: Color::Blue,
|
link: Color::Blue,
|
||||||
quote: Color::DarkGrey,
|
quote: Color::DarkGrey,
|
||||||
table_border: Color::DarkCyan,
|
table_border: Color::DarkCyan,
|
||||||
code_block_border: Color::DarkGrey,
|
|
||||||
spinner_active: Color::Blue,
|
spinner_active: Color::Blue,
|
||||||
spinner_done: Color::Green,
|
spinner_done: Color::Green,
|
||||||
spinner_failed: Color::Red,
|
spinner_failed: Color::Red,
|
||||||
@@ -154,64 +154,33 @@ impl TableState {
|
|||||||
struct RenderState {
|
struct RenderState {
|
||||||
emphasis: usize,
|
emphasis: usize,
|
||||||
strong: usize,
|
strong: usize,
|
||||||
heading_level: Option<u8>,
|
|
||||||
quote: usize,
|
quote: usize,
|
||||||
list_stack: Vec<ListKind>,
|
list_stack: Vec<ListKind>,
|
||||||
link_stack: Vec<LinkState>,
|
|
||||||
table: Option<TableState>,
|
table: Option<TableState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
struct LinkState {
|
|
||||||
destination: String,
|
|
||||||
text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderState {
|
impl RenderState {
|
||||||
fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
|
fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
|
||||||
let mut style = text.stylize();
|
let mut styled = text.to_string();
|
||||||
|
if self.strong > 0 {
|
||||||
if matches!(self.heading_level, Some(1 | 2)) || self.strong > 0 {
|
styled = format!("{}", styled.bold().with(theme.strong));
|
||||||
style = style.bold();
|
|
||||||
}
|
}
|
||||||
if self.emphasis > 0 {
|
if self.emphasis > 0 {
|
||||||
style = style.italic();
|
styled = format!("{}", styled.italic().with(theme.emphasis));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(level) = self.heading_level {
|
|
||||||
style = match level {
|
|
||||||
1 => style.with(theme.heading),
|
|
||||||
2 => style.white(),
|
|
||||||
3 => style.with(Color::Blue),
|
|
||||||
_ => style.with(Color::Grey),
|
|
||||||
};
|
|
||||||
} else if self.strong > 0 {
|
|
||||||
style = style.with(theme.strong);
|
|
||||||
} else if self.emphasis > 0 {
|
|
||||||
style = style.with(theme.emphasis);
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.quote > 0 {
|
if self.quote > 0 {
|
||||||
style = style.with(theme.quote);
|
styled = format!("{}", styled.with(theme.quote));
|
||||||
}
|
}
|
||||||
|
styled
|
||||||
format!("{style}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_raw(&mut self, output: &mut String, text: &str) {
|
fn capture_target_mut<'a>(&'a mut self, output: &'a mut String) -> &'a mut String {
|
||||||
if let Some(link) = self.link_stack.last_mut() {
|
if let Some(table) = self.table.as_mut() {
|
||||||
link.text.push_str(text);
|
&mut table.current_cell
|
||||||
} else if let Some(table) = self.table.as_mut() {
|
|
||||||
table.current_cell.push_str(text);
|
|
||||||
} else {
|
} else {
|
||||||
output.push_str(text);
|
output
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_styled(&mut self, output: &mut String, text: &str, theme: &ColorTheme) {
|
|
||||||
let styled = self.style_text(text, theme);
|
|
||||||
self.append_raw(output, &styled);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -269,11 +238,6 @@ impl TerminalRenderer {
|
|||||||
output.trim_end().to_string()
|
output.trim_end().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn markdown_to_ansi(&self, markdown: &str) -> String {
|
|
||||||
self.render_markdown(markdown)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
fn render_event(
|
fn render_event(
|
||||||
&self,
|
&self,
|
||||||
@@ -285,21 +249,15 @@ impl TerminalRenderer {
|
|||||||
in_code_block: &mut bool,
|
in_code_block: &mut bool,
|
||||||
) {
|
) {
|
||||||
match event {
|
match event {
|
||||||
Event::Start(Tag::Heading { level, .. }) => {
|
Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output),
|
||||||
self.start_heading(state, level as u8, output)
|
Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"),
|
||||||
}
|
|
||||||
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
|
|
||||||
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
|
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
|
||||||
Event::End(TagEnd::BlockQuote(..)) => {
|
Event::End(TagEnd::BlockQuote(..)) => {
|
||||||
state.quote = state.quote.saturating_sub(1);
|
state.quote = state.quote.saturating_sub(1);
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
}
|
}
|
||||||
Event::End(TagEnd::Heading(..)) => {
|
|
||||||
state.heading_level = None;
|
|
||||||
output.push_str("\n\n");
|
|
||||||
}
|
|
||||||
Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => {
|
Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => {
|
||||||
state.append_raw(output, "\n");
|
state.capture_target_mut(output).push('\n');
|
||||||
}
|
}
|
||||||
Event::Start(Tag::List(first_item)) => {
|
Event::Start(Tag::List(first_item)) => {
|
||||||
let kind = match first_item {
|
let kind = match first_item {
|
||||||
@@ -335,52 +293,41 @@ impl TerminalRenderer {
|
|||||||
Event::Code(code) => {
|
Event::Code(code) => {
|
||||||
let rendered =
|
let rendered =
|
||||||
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
|
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
|
||||||
state.append_raw(output, &rendered);
|
state.capture_target_mut(output).push_str(&rendered);
|
||||||
}
|
}
|
||||||
Event::Rule => output.push_str("---\n"),
|
Event::Rule => output.push_str("---\n"),
|
||||||
Event::Text(text) => {
|
Event::Text(text) => {
|
||||||
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
|
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
|
||||||
}
|
}
|
||||||
Event::Html(html) | Event::InlineHtml(html) => {
|
Event::Html(html) | Event::InlineHtml(html) => {
|
||||||
state.append_raw(output, &html);
|
state.capture_target_mut(output).push_str(&html);
|
||||||
}
|
}
|
||||||
Event::FootnoteReference(reference) => {
|
Event::FootnoteReference(reference) => {
|
||||||
state.append_raw(output, &format!("[{reference}]"));
|
let _ = write!(state.capture_target_mut(output), "[{reference}]");
|
||||||
}
|
}
|
||||||
Event::TaskListMarker(done) => {
|
Event::TaskListMarker(done) => {
|
||||||
state.append_raw(output, if done { "[x] " } else { "[ ] " });
|
state
|
||||||
|
.capture_target_mut(output)
|
||||||
|
.push_str(if done { "[x] " } else { "[ ] " });
|
||||||
}
|
}
|
||||||
Event::InlineMath(math) | Event::DisplayMath(math) => {
|
Event::InlineMath(math) | Event::DisplayMath(math) => {
|
||||||
state.append_raw(output, &math);
|
state.capture_target_mut(output).push_str(&math);
|
||||||
}
|
}
|
||||||
Event::Start(Tag::Link { dest_url, .. }) => {
|
Event::Start(Tag::Link { dest_url, .. }) => {
|
||||||
state.link_stack.push(LinkState {
|
let rendered = format!(
|
||||||
destination: dest_url.to_string(),
|
"{}",
|
||||||
text: String::new(),
|
format!("[{dest_url}]")
|
||||||
});
|
.underlined()
|
||||||
}
|
.with(self.color_theme.link)
|
||||||
Event::End(TagEnd::Link) => {
|
);
|
||||||
if let Some(link) = state.link_stack.pop() {
|
state.capture_target_mut(output).push_str(&rendered);
|
||||||
let label = if link.text.is_empty() {
|
|
||||||
link.destination.clone()
|
|
||||||
} else {
|
|
||||||
link.text
|
|
||||||
};
|
|
||||||
let rendered = format!(
|
|
||||||
"{}",
|
|
||||||
format!("[{label}]({})", link.destination)
|
|
||||||
.underlined()
|
|
||||||
.with(self.color_theme.link)
|
|
||||||
);
|
|
||||||
state.append_raw(output, &rendered);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Event::Start(Tag::Image { dest_url, .. }) => {
|
Event::Start(Tag::Image { dest_url, .. }) => {
|
||||||
let rendered = format!(
|
let rendered = format!(
|
||||||
"{}",
|
"{}",
|
||||||
format!("[image:{dest_url}]").with(self.color_theme.link)
|
format!("[image:{dest_url}]").with(self.color_theme.link)
|
||||||
);
|
);
|
||||||
state.append_raw(output, &rendered);
|
state.capture_target_mut(output).push_str(&rendered);
|
||||||
}
|
}
|
||||||
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
|
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
|
||||||
Event::End(TagEnd::Table) => {
|
Event::End(TagEnd::Table) => {
|
||||||
@@ -422,15 +369,19 @@ impl TerminalRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _)
|
Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _)
|
||||||
| Event::End(TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {}
|
| Event::End(TagEnd::Link | TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_heading(&self, state: &mut RenderState, level: u8, output: &mut String) {
|
fn start_heading(&self, level: u8, output: &mut String) {
|
||||||
state.heading_level = Some(level);
|
output.push('\n');
|
||||||
if !output.is_empty() {
|
let prefix = match level {
|
||||||
output.push('\n');
|
1 => "# ",
|
||||||
}
|
2 => "## ",
|
||||||
|
3 => "### ",
|
||||||
|
_ => "#### ",
|
||||||
|
};
|
||||||
|
let _ = write!(output, "{}", prefix.bold().with(self.color_theme.heading));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_quote(&self, state: &mut RenderState, output: &mut String) {
|
fn start_quote(&self, state: &mut RenderState, output: &mut String) {
|
||||||
@@ -454,27 +405,20 @@ impl TerminalRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn start_code_block(&self, code_language: &str, output: &mut String) {
|
fn start_code_block(&self, code_language: &str, output: &mut String) {
|
||||||
let label = if code_language.is_empty() {
|
if !code_language.is_empty() {
|
||||||
"code".to_string()
|
let _ = writeln!(
|
||||||
} else {
|
output,
|
||||||
code_language.to_string()
|
"{}",
|
||||||
};
|
format!("╭─ {code_language}").with(self.color_theme.heading)
|
||||||
let _ = writeln!(
|
);
|
||||||
output,
|
}
|
||||||
"{}",
|
|
||||||
format!("╭─ {label}")
|
|
||||||
.bold()
|
|
||||||
.with(self.color_theme.code_block_border)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) {
|
fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) {
|
||||||
output.push_str(&self.highlight_code(code_buffer, code_language));
|
output.push_str(&self.highlight_code(code_buffer, code_language));
|
||||||
let _ = write!(
|
if !code_language.is_empty() {
|
||||||
output,
|
let _ = write!(output, "{}", "╰─".with(self.color_theme.heading));
|
||||||
"{}",
|
}
|
||||||
"╰─".bold().with(self.color_theme.code_block_border)
|
|
||||||
);
|
|
||||||
output.push_str("\n\n");
|
output.push_str("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,7 +433,8 @@ impl TerminalRenderer {
|
|||||||
if in_code_block {
|
if in_code_block {
|
||||||
code_buffer.push_str(text);
|
code_buffer.push_str(text);
|
||||||
} else {
|
} else {
|
||||||
state.append_styled(output, text, &self.color_theme);
|
let rendered = state.style_text(text, &self.color_theme);
|
||||||
|
state.capture_target_mut(output).push_str(&rendered);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,10 +521,9 @@ impl TerminalRenderer {
|
|||||||
for line in LinesWithEndings::from(code) {
|
for line in LinesWithEndings::from(code) {
|
||||||
match syntax_highlighter.highlight_line(line, &self.syntax_set) {
|
match syntax_highlighter.highlight_line(line, &self.syntax_set) {
|
||||||
Ok(ranges) => {
|
Ok(ranges) => {
|
||||||
let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
|
colored_output.push_str(&as_24_bit_terminal_escaped(&ranges[..], false));
|
||||||
colored_output.push_str(&apply_code_block_background(&escaped));
|
|
||||||
}
|
}
|
||||||
Err(_) => colored_output.push_str(&apply_code_block_background(line)),
|
Err(_) => colored_output.push_str(line),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,83 +531,16 @@ impl TerminalRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> {
|
pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> {
|
||||||
let rendered_markdown = self.markdown_to_ansi(markdown);
|
let rendered_markdown = self.render_markdown(markdown);
|
||||||
write!(out, "{rendered_markdown}")?;
|
for chunk in rendered_markdown.split_inclusive(char::is_whitespace) {
|
||||||
if !rendered_markdown.ends_with('\n') {
|
write!(out, "{chunk}")?;
|
||||||
writeln!(out)?;
|
out.flush()?;
|
||||||
|
thread::sleep(Duration::from_millis(8));
|
||||||
}
|
}
|
||||||
out.flush()
|
writeln!(out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
|
||||||
pub struct MarkdownStreamState {
|
|
||||||
pending: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MarkdownStreamState {
|
|
||||||
#[must_use]
|
|
||||||
pub fn push(&mut self, renderer: &TerminalRenderer, delta: &str) -> Option<String> {
|
|
||||||
self.pending.push_str(delta);
|
|
||||||
let split = find_stream_safe_boundary(&self.pending)?;
|
|
||||||
let ready = self.pending[..split].to_string();
|
|
||||||
self.pending.drain(..split);
|
|
||||||
Some(renderer.markdown_to_ansi(&ready))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn flush(&mut self, renderer: &TerminalRenderer) -> Option<String> {
|
|
||||||
if self.pending.trim().is_empty() {
|
|
||||||
self.pending.clear();
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let pending = std::mem::take(&mut self.pending);
|
|
||||||
Some(renderer.markdown_to_ansi(&pending))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_code_block_background(line: &str) -> String {
|
|
||||||
let trimmed = line.trim_end_matches('\n');
|
|
||||||
let trailing_newline = if trimmed.len() == line.len() {
|
|
||||||
""
|
|
||||||
} else {
|
|
||||||
"\n"
|
|
||||||
};
|
|
||||||
let with_background = trimmed.replace("\u{1b}[0m", "\u{1b}[0;48;5;236m");
|
|
||||||
format!("\u{1b}[48;5;236m{with_background}\u{1b}[0m{trailing_newline}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
|
|
||||||
let mut in_fence = false;
|
|
||||||
let mut last_boundary = None;
|
|
||||||
|
|
||||||
for (offset, line) in markdown.split_inclusive('\n').scan(0usize, |cursor, line| {
|
|
||||||
let start = *cursor;
|
|
||||||
*cursor += line.len();
|
|
||||||
Some((start, line))
|
|
||||||
}) {
|
|
||||||
let trimmed = line.trim_start();
|
|
||||||
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
|
|
||||||
in_fence = !in_fence;
|
|
||||||
if !in_fence {
|
|
||||||
last_boundary = Some(offset + line.len());
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if in_fence {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
last_boundary = Some(offset + line.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
last_boundary
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visible_width(input: &str) -> usize {
|
fn visible_width(input: &str) -> usize {
|
||||||
strip_ansi(input).chars().count()
|
strip_ansi(input).chars().count()
|
||||||
}
|
}
|
||||||
@@ -692,7 +569,7 @@ fn strip_ansi(input: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{strip_ansi, MarkdownStreamState, Spinner, TerminalRenderer};
|
use super::{strip_ansi, Spinner, TerminalRenderer};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_markdown_with_styling_and_lists() {
|
fn renders_markdown_with_styling_and_lists() {
|
||||||
@@ -706,28 +583,16 @@ mod tests {
|
|||||||
assert!(markdown_output.contains('\u{1b}'));
|
assert!(markdown_output.contains('\u{1b}'));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn renders_links_as_colored_markdown_labels() {
|
|
||||||
let terminal_renderer = TerminalRenderer::new();
|
|
||||||
let markdown_output =
|
|
||||||
terminal_renderer.render_markdown("See [Claw](https://example.com/docs) now.");
|
|
||||||
let plain_text = strip_ansi(&markdown_output);
|
|
||||||
|
|
||||||
assert!(plain_text.contains("[Claw](https://example.com/docs)"));
|
|
||||||
assert!(markdown_output.contains('\u{1b}'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn highlights_fenced_code_blocks() {
|
fn highlights_fenced_code_blocks() {
|
||||||
let terminal_renderer = TerminalRenderer::new();
|
let terminal_renderer = TerminalRenderer::new();
|
||||||
let markdown_output =
|
let markdown_output =
|
||||||
terminal_renderer.markdown_to_ansi("```rust\nfn hi() { println!(\"hi\"); }\n```");
|
terminal_renderer.render_markdown("```rust\nfn hi() { println!(\"hi\"); }\n```");
|
||||||
let plain_text = strip_ansi(&markdown_output);
|
let plain_text = strip_ansi(&markdown_output);
|
||||||
|
|
||||||
assert!(plain_text.contains("╭─ rust"));
|
assert!(plain_text.contains("╭─ rust"));
|
||||||
assert!(plain_text.contains("fn hi"));
|
assert!(plain_text.contains("fn hi"));
|
||||||
assert!(markdown_output.contains('\u{1b}'));
|
assert!(markdown_output.contains('\u{1b}'));
|
||||||
assert!(markdown_output.contains("[48;5;236m"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -758,26 +623,6 @@ mod tests {
|
|||||||
assert!(markdown_output.contains('\u{1b}'));
|
assert!(markdown_output.contains('\u{1b}'));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn streaming_state_waits_for_complete_blocks() {
|
|
||||||
let renderer = TerminalRenderer::new();
|
|
||||||
let mut state = MarkdownStreamState::default();
|
|
||||||
|
|
||||||
assert_eq!(state.push(&renderer, "# Heading"), None);
|
|
||||||
let flushed = state
|
|
||||||
.push(&renderer, "\n\nParagraph\n\n")
|
|
||||||
.expect("completed block");
|
|
||||||
let plain_text = strip_ansi(&flushed);
|
|
||||||
assert!(plain_text.contains("Heading"));
|
|
||||||
assert!(plain_text.contains("Paragraph"));
|
|
||||||
|
|
||||||
assert_eq!(state.push(&renderer, "```rust\nfn main() {}\n"), None);
|
|
||||||
let code = state
|
|
||||||
.push(&renderer, "```\n")
|
|
||||||
.expect("closed code fence flushes");
|
|
||||||
assert!(strip_ansi(&code).contains("fn main()"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spinner_advances_frames() {
|
fn spinner_advances_frames() {
|
||||||
let terminal_renderer = TerminalRenderer::new();
|
let terminal_renderer = TerminalRenderer::new();
|
||||||
|
|||||||
@@ -1259,7 +1259,15 @@ fn validate_todos(todos: &[TodoItem]) -> Result<(), String> {
|
|||||||
if todos.is_empty() {
|
if todos.is_empty() {
|
||||||
return Err(String::from("todos must not be empty"));
|
return Err(String::from("todos must not be empty"));
|
||||||
}
|
}
|
||||||
// Allow multiple in_progress items for parallel workflows
|
let in_progress = todos
|
||||||
|
.iter()
|
||||||
|
.filter(|todo| matches!(todo.status, TodoStatus::InProgress))
|
||||||
|
.count();
|
||||||
|
if in_progress > 1 {
|
||||||
|
return Err(String::from(
|
||||||
|
"exactly zero or one todo items may be in_progress",
|
||||||
|
));
|
||||||
|
}
|
||||||
if todos.iter().any(|todo| todo.content.trim().is_empty()) {
|
if todos.iter().any(|todo| todo.content.trim().is_empty()) {
|
||||||
return Err(String::from("todo content must not be empty"));
|
return Err(String::from("todo content must not be empty"));
|
||||||
}
|
}
|
||||||
@@ -2207,7 +2215,7 @@ fn execute_shell_command(
|
|||||||
persisted_output_path: None,
|
persisted_output_path: None,
|
||||||
persisted_output_size: None,
|
persisted_output_size: None,
|
||||||
sandbox_status: None,
|
sandbox_status: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut process = std::process::Command::new(shell);
|
let mut process = std::process::Command::new(shell);
|
||||||
@@ -2276,7 +2284,7 @@ Command exceeded timeout of {timeout_ms} ms",
|
|||||||
persisted_output_path: None,
|
persisted_output_path: None,
|
||||||
persisted_output_size: None,
|
persisted_output_size: None,
|
||||||
sandbox_status: None,
|
sandbox_status: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
@@ -2646,8 +2654,7 @@ mod tests {
|
|||||||
.expect_err("empty todos should fail");
|
.expect_err("empty todos should fail");
|
||||||
assert!(empty.contains("todos must not be empty"));
|
assert!(empty.contains("todos must not be empty"));
|
||||||
|
|
||||||
// Multiple in_progress items are now allowed for parallel workflows
|
let too_many_active = execute_tool(
|
||||||
let _multi_active = execute_tool(
|
|
||||||
"TodoWrite",
|
"TodoWrite",
|
||||||
&json!({
|
&json!({
|
||||||
"todos": [
|
"todos": [
|
||||||
@@ -2656,7 +2663,8 @@ mod tests {
|
|||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.expect("multiple in-progress todos should succeed");
|
.expect_err("multiple in-progress todos should fail");
|
||||||
|
assert!(too_many_active.contains("zero or one todo items may be in_progress"));
|
||||||
|
|
||||||
let blank_content = execute_tool(
|
let blank_content = execute_tool(
|
||||||
"TodoWrite",
|
"TodoWrite",
|
||||||
|
|||||||
Reference in New Issue
Block a user