From 0ca05e3de3533f0a781a31678d123d002f719c20 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 5 Feb 2026 19:46:43 -0800 Subject: [PATCH 1/9] Seed HEARTBEAT.md on first access and skip effectively-empty checklists The heartbeat feature was dead on arrival: nothing ever created HEARTBEAT.md, so the runner silently skipped every cycle. Now the workspace returns an in-memory seed template when the file doesn't exist in the database (no DB write), and the runner detects "effectively empty" content (headers, HTML comments, bare list markers) to avoid wasting LLM API calls on placeholder templates. The user creates the real DB entry via memory_write when they actually want periodic checks. Co-Authored-By: Claude Opus 4.6 --- src/agent/heartbeat.rs | 144 ++++++++++++++++++++++++++++++++++++++++- src/workspace/mod.rs | 26 +++++++- 2 files changed, 168 insertions(+), 2 deletions(-) diff --git a/src/agent/heartbeat.rs b/src/agent/heartbeat.rs index e6f25379..115b8159 100644 --- a/src/agent/heartbeat.rs +++ b/src/agent/heartbeat.rs @@ -178,7 +178,7 @@ impl HeartbeatRunner { pub async fn check_heartbeat(&self) -> HeartbeatResult { // Get the heartbeat checklist let checklist = match self.workspace.heartbeat_checklist().await { - Ok(Some(content)) if !content.trim().is_empty() => content, + Ok(Some(content)) if !is_effectively_empty(&content) => content, Ok(_) => return HeartbeatResult::Skipped, Err(e) => return HeartbeatResult::Failed(format!("Failed to read checklist: {}", e)), }; @@ -257,6 +257,45 @@ impl HeartbeatRunner { } } +/// Check if heartbeat content is effectively empty. +/// +/// Returns true if the content contains only: +/// - Whitespace +/// - Markdown headers (lines starting with #) +/// - HTML comments (``) +/// - Empty list items (`- [ ]`, `- [x]`, `-`, `*`) +/// +/// This skips the LLM call when the user hasn't added real tasks yet, +/// saving API costs. +fn is_effectively_empty(content: &str) -> bool { + let without_comments = strip_html_comments(content); + + without_comments.lines().all(|line| { + let trimmed = line.trim(); + trimmed.is_empty() + || trimmed.starts_with('#') + || trimmed == "- [ ]" + || trimmed == "- [x]" + || trimmed == "-" + || trimmed == "*" + }) +} + +/// Remove HTML comments from content. +fn strip_html_comments(content: &str) -> String { + let mut result = String::with_capacity(content.len()); + let mut rest = content; + while let Some(start) = rest.find("") { + Some(end) => rest = &rest[start + end + 3..], + None => return result, // unclosed comment, treat rest as comment + } + } + result.push_str(rest); + result +} + /// Spawn the heartbeat runner as a background task. /// /// Returns a handle that can be used to stop the runner. @@ -301,4 +340,107 @@ mod tests { let disabled = HeartbeatConfig::default().disabled(); assert!(!disabled.enabled); } + + // ==================== strip_html_comments ==================== + + #[test] + fn test_strip_html_comments_no_comments() { + assert_eq!(strip_html_comments("hello world"), "hello world"); + } + + #[test] + fn test_strip_html_comments_single() { + assert_eq!( + strip_html_comments("beforeafter"), + "beforeafter" + ); + } + + #[test] + fn test_strip_html_comments_multiple() { + let input = "abc"; + assert_eq!(strip_html_comments(input), "abc"); + } + + #[test] + fn test_strip_html_comments_multiline() { + let input = "# Title\n\nreal content"; + assert_eq!(strip_html_comments(input), "# Title\n\nreal content"); + } + + #[test] + fn test_strip_html_comments_unclosed() { + let input = "before")); + } + + #[test] + fn test_effectively_empty_empty_checkboxes() { + assert!(is_effectively_empty("# Checklist\n- [ ]\n- [x]")); + } + + #[test] + fn test_effectively_empty_bare_list_markers() { + assert!(is_effectively_empty("-\n*\n-")); + } + + #[test] + fn test_effectively_empty_seeded_template() { + let template = "\ +# Heartbeat Checklist + +"; + assert!(is_effectively_empty(template)); + } + + #[test] + fn test_effectively_empty_real_checklist() { + let content = "\ +# Heartbeat Checklist + +- [ ] Check for unread emails needing a reply +- [ ] Review today's calendar for upcoming meetings"; + assert!(!is_effectively_empty(content)); + } + + #[test] + fn test_effectively_empty_mixed_real_and_headers() { + let content = "# Title\n\nDo something important"; + assert!(!is_effectively_empty(content)); + } + + #[test] + fn test_effectively_empty_comment_plus_real_content() { + let content = "\nActual task here"; + assert!(!is_effectively_empty(content)); + } } diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 32cc2121..24f77217 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -60,6 +60,23 @@ use uuid::Uuid; use crate::error::WorkspaceError; +/// Default template seeded into HEARTBEAT.md on first access. +/// +/// Intentionally comment-only so the heartbeat runner treats it as +/// "effectively empty" and skips the LLM call until the user adds +/// real tasks. +const HEARTBEAT_SEED: &str = "\ +# Heartbeat Checklist + +"; + /// Workspace provides database-backed memory storage for an agent. /// /// Each workspace is scoped to a user (and optionally an agent). @@ -246,10 +263,17 @@ impl Workspace { } /// Get the heartbeat checklist (HEARTBEAT.md). + /// + /// Returns the DB-stored checklist if it exists, otherwise falls back + /// to the in-memory seed template. The seed is never written to the + /// database; the user creates the real file via `memory_write` when + /// they actually want periodic checks. The seed content is all HTML + /// comments, which the heartbeat runner treats as "effectively empty" + /// and skips the LLM call. pub async fn heartbeat_checklist(&self) -> Result, WorkspaceError> { match self.read(paths::HEARTBEAT).await { Ok(doc) => Ok(Some(doc.content)), - Err(WorkspaceError::DocumentNotFound { .. }) => Ok(None), + Err(WorkspaceError::DocumentNotFound { .. }) => Ok(Some(HEARTBEAT_SEED.to_string())), Err(e) => Err(e), } } From 7fcc2279ccaba68494f10bed4dd0480f1972e47c Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 5 Feb 2026 20:02:33 -0800 Subject: [PATCH 2/9] Route HEARTBEAT writes to workspace DB and broadcast notifications - Add dedicated "heartbeat" target in memory_write tool so the LLM routes HEARTBEAT.md writes to the database instead of the filesystem - Update tool description to clarify it's database-backed storage - Broadcast heartbeat notifications to all channels when no explicit notify target is configured, instead of silently logging them Co-Authored-By: Claude Opus 4.6 --- src/agent/agent_loop.rs | 18 +++++++++++++----- src/tools/builtin/memory.rs | 25 ++++++++++++++++++++----- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index bdcc43b6..72397b9a 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -212,11 +212,19 @@ impl Agent { } } _ => { - // No target configured, just log - tracing::info!( - "Heartbeat notification (no target configured): {}", - &response.content - ); + // No explicit target, broadcast to all channels + // for the default user so notifications actually + // reach someone instead of vanishing into logs. + let results = channels.broadcast_all("default", response).await; + for (ch, result) in results { + if let Err(e) = result { + tracing::warn!( + "Failed to broadcast heartbeat to {}: {}", + ch, + e + ); + } + } } } } diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs index d50b4ee4..d77b3c59 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -133,10 +133,11 @@ impl Tool for MemoryWriteTool { } fn description(&self) -> &str { - "Write to persistent memory. Use for important facts, decisions, preferences, \ - or lessons learned that should be remembered across sessions. Use 'memory' target \ - for curated long-term facts, 'daily_log' for timestamped session notes, or \ - provide a custom path for arbitrary file creation." + "Write to persistent memory (database-backed, NOT the local filesystem). \ + Use for important facts, decisions, preferences, or lessons learned that should \ + be remembered across sessions. Targets: 'memory' for curated long-term facts, \ + 'daily_log' for timestamped session notes, 'heartbeat' for the periodic \ + checklist (HEARTBEAT.md), or provide a custom path for arbitrary file creation." } fn parameters_schema(&self) -> serde_json::Value { @@ -149,7 +150,7 @@ impl Tool for MemoryWriteTool { }, "target": { "type": "string", - "description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, or a path like 'projects/alpha/notes.md'", + "description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, 'heartbeat' for HEARTBEAT.md checklist, or a path like 'projects/alpha/notes.md'", "default": "daily_log" }, "append": { @@ -214,6 +215,20 @@ impl Tool for MemoryWriteTool { .map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?; format!("daily/{}.md", chrono::Utc::now().format("%Y-%m-%d")) } + "heartbeat" => { + if append { + self.workspace + .append(paths::HEARTBEAT, content) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?; + } else { + self.workspace + .write(paths::HEARTBEAT, content) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?; + } + paths::HEARTBEAT.to_string() + } path => { if append { self.workspace From 4f8fd4ad5fe49c51827e2c82b55f3ed94030539b Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 5 Feb 2026 20:15:53 -0800 Subject: [PATCH 3/9] Reject workspace paths in write_file, force LLM to use memory_write write_file now detects workspace files (HEARTBEAT.md, MEMORY.md, SOUL.md, etc.) and daily/context/ prefixes, returning an error that tells the LLM to use memory_write with the correct target instead. This prevents the LLM from writing workspace data to the local filesystem when it should go to the database. Co-Authored-By: Claude Opus 4.6 --- src/tools/builtin/file.rs | 109 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/tools/builtin/file.rs b/src/tools/builtin/file.rs index 1b6ebf90..e72df9f8 100644 --- a/src/tools/builtin/file.rs +++ b/src/tools/builtin/file.rs @@ -12,6 +12,34 @@ use tokio::fs; use crate::context::JobContext; use crate::tools::tool::{Tool, ToolError, ToolOutput}; +use crate::workspace::paths as ws_paths; + +/// Well-known workspace filenames that must go through memory_write, not write_file. +/// +/// If the LLM tries to write one of these via the filesystem tool we reject +/// immediately and point it at the correct tool. +const WORKSPACE_FILES: &[&str] = &[ + ws_paths::HEARTBEAT, + ws_paths::MEMORY, + ws_paths::IDENTITY, + ws_paths::SOUL, + ws_paths::AGENTS, + ws_paths::USER, + ws_paths::README, +]; + +/// Check whether `path` resolves to a workspace file that should be written +/// through `memory_write` instead of `write_file`. +fn is_workspace_path(path: &str) -> bool { + let filename = std::path::Path::new(path) + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or(path); + + WORKSPACE_FILES.iter().any(|ws| *ws == filename) + || path.starts_with("daily/") + || path.starts_with("context/") +} /// Maximum file size for reading (1MB). const MAX_READ_SIZE: u64 = 1024 * 1024; @@ -276,6 +304,15 @@ impl Tool for WriteFileTool { .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?; + // Reject workspace paths: these live in the database, not on disk. + if is_workspace_path(path_str) { + return Err(ToolError::InvalidParameters(format!( + "'{}' is a workspace memory file. Use the memory_write tool instead of write_file. \ + For HEARTBEAT.md use target='heartbeat', for MEMORY.md use target='memory'.", + path_str + ))); + } + let content = params .get("content") .and_then(|v| v.as_str()) @@ -726,6 +763,78 @@ mod tests { assert!(content.contains("println!(\"new\")")); } + #[tokio::test] + async fn test_write_file_rejects_workspace_paths() { + let dir = TempDir::new().unwrap(); + let tool = WriteFileTool::new().with_base_dir(dir.path().to_path_buf()); + let ctx = JobContext::default(); + + let workspace_files = &[ + "HEARTBEAT.md", + "MEMORY.md", + "IDENTITY.md", + "SOUL.md", + "AGENTS.md", + "USER.md", + "README.md", + ]; + + for filename in workspace_files { + let path = dir.path().join(filename); + let err = tool + .execute( + serde_json::json!({ + "path": path.to_str().unwrap(), + "content": "test" + }), + &ctx, + ) + .await + .unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("memory_write"), + "Rejection for {} should mention memory_write, got: {}", + filename, + msg + ); + } + + // daily/ and context/ prefixes should also be rejected + for prefix_path in &["daily/2024-01-15.md", "context/vision.md"] { + let err = tool + .execute( + serde_json::json!({ + "path": prefix_path, + "content": "test" + }), + &ctx, + ) + .await + .unwrap_err(); + + assert!( + err.to_string().contains("memory_write"), + "Rejection for {} should mention memory_write", + prefix_path + ); + } + + // Regular files should still work + let regular_path = dir.path().join("normal.txt"); + let result = tool + .execute( + serde_json::json!({ + "path": regular_path.to_str().unwrap(), + "content": "fine" + }), + &ctx, + ) + .await; + assert!(result.is_ok()); + } + #[tokio::test] async fn test_list_dir() { let dir = TempDir::new().unwrap(); From 3c54e692a5670ab55dd5176968b60cf8f3c96960 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 5 Feb 2026 21:54:50 -0800 Subject: [PATCH 4/9] Split LICENSE into LICENSE-MIT and LICENSE-APACHE per README The README references LICENSE-MIT and LICENSE-APACHE for the dual MIT/Apache-2.0 license, matching the Cargo.toml declaration and the standard Rust convention. Rename the existing MIT file and add the Apache 2.0 text. Co-Authored-By: Claude Opus 4.6 --- LICENSE-APACHE | 191 +++++++++++++++++++++++++++++++++++++++++ LICENSE => LICENSE-MIT | 0 2 files changed, 191 insertions(+) create mode 100644 LICENSE-APACHE rename LICENSE => LICENSE-MIT (100%) diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 00000000..8f482bce --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 NEAR AI + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE b/LICENSE-MIT similarity index 100% rename from LICENSE rename to LICENSE-MIT From a93c7ed8938ee6f77164fcf4ead816ab4097541d Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 6 Feb 2026 08:51:24 -0800 Subject: [PATCH 5/9] Fix README drift from codebase reality Channels listed CLI/Telegram/WhatsApp/Slack but only REPL + HTTP are built-in (Telegram/Slack are WASM channels, WhatsApp never existed). Auth section required a manual session token but the actual flow uses OAuth via `ironclaw setup`. Config pointed at a nonexistent refinery.toml, used the wrong default model, and the curl example had the wrong field name. Updated all sections to match the code. Co-Authored-By: Claude Opus 4.6 --- README.md | 58 ++++++++++++++----------------------------------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index d815c833..ce78526a 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe ### Always Available -- **Multi-channel** - Reach your assistant via CLI, Telegram, WhatsApp, Slack, or HTTP webhooks +- **Multi-channel** - REPL, HTTP webhooks, and extensible WASM channels (Telegram, Slack, and more) - **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks - **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts - **Self-repair** - Automatic detection and recovery of stuck operations @@ -66,7 +66,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe - Rust 1.85+ - PostgreSQL 15+ with pgvector extension -- NEAR AI session token (or other LLM provider) +- NEAR AI account (authentication handled via setup wizard) ### Build @@ -90,36 +90,19 @@ createdb ironclaw # Enable pgvector psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;" - -# Run migrations -refinery migrate -c refinery.toml ``` ## Configuration -Copy `.env.example` to `.env` and configure: +Run the setup wizard to configure IronClaw: ```bash -# Required -DATABASE_URL=postgres://user:pass@localhost/ironclaw -NEARAI_SESSION_TOKEN=sess_... - -# Optional: Enable channels -TELEGRAM_BOT_TOKEN=... -WHATSAPP_ACCESS_TOKEN=... -SLACK_BOT_TOKEN=xoxb-... -HTTP_PORT=8080 +ironclaw setup ``` -### Environment Variables - -| Variable | Description | Required | -|----------|-------------|----------| -| `DATABASE_URL` | PostgreSQL connection string | Yes | -| `NEARAI_SESSION_TOKEN` | NEAR AI authentication token | Yes | -| `NEARAI_MODEL` | Model to use (default: claude-3-5-sonnet) | No | -| `AGENT_MAX_PARALLEL_JOBS` | Max concurrent jobs (default: 5) | No | -| `SECRETS_MASTER_KEY` | 32+ byte key for secret encryption | For secrets | +The wizard handles database connection, NEAR AI authentication (via browser OAuth), +and secrets encryption (using your system keychain). All settings are saved to +`~/.ironclaw/settings.toml`. ## Security @@ -162,10 +145,10 @@ External content passes through multiple security layers: ``` ┌─────────────────────────────────────────────────────────────────┐ │ Channels │ -│ ┌─────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │ -│ │ CLI │ │ Telegram │ │ WhatsApp │ │ Slack │ │ -│ └──┬──┘ └────┬─────┘ └────┬─────┘ └───┬───┘ │ -│ └──────────┴─────────────┴────────────┘ │ +│ ┌──────┐ ┌──────┐ ┌──────────────┐ │ +│ │ REPL │ │ HTTP │ │ WASM Channels│ │ +│ └──┬───┘ └──┬───┘ └──────┬───────┘ │ +│ └─────────┴─────────────┘ │ │ │ │ │ ┌────▼────┐ │ │ │ Router │ Intent classification │ @@ -206,28 +189,17 @@ External content passes through multiple security layers: ## Usage -### CLI Mode - ```bash -# Start interactive CLI +# First-time setup (configures database, auth, etc.) +ironclaw setup + +# Start interactive REPL cargo run # With debug logging RUST_LOG=ironclaw=debug cargo run ``` -### HTTP Server - -```bash -# Start with HTTP webhook server -HTTP_PORT=8080 cargo run - -# Send a request -curl -X POST http://localhost:8080/webhook \ - -H "Content-Type: application/json" \ - -d '{"message": "Hello, IronClaw!"}' -``` - ## Development ```bash From 4d0fe7d37e3f44072d6136ec72678cf81a883be5 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 6 Feb 2026 09:00:32 -0800 Subject: [PATCH 6/9] Replace TUI (ratatui) with REPL (rustyline + termimad) Drop the full Ratatui TUI in favor of a lighter REPL channel built on rustyline (line editing, history, tab-completion) and termimad (inline markdown rendering). Removes ratatui and crossterm event-stream deps, adds rustyline and termimad. Simplifies main.rs startup to use the REPL directly instead of the alternate-screen TUI. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 457 ++++++++++++++++++----------- Cargo.toml | 7 +- src/channels/cli/app.rs | 359 ---------------------- src/channels/cli/composer.rs | 318 -------------------- src/channels/cli/events.rs | 333 --------------------- src/channels/cli/mod.rs | 238 --------------- src/channels/cli/model_selector.rs | 156 ---------- src/channels/cli/overlay.rs | 145 --------- src/channels/cli/render.rs | 341 --------------------- src/channels/mod.rs | 8 +- src/channels/repl.rs | 236 ++++++++++++--- src/cli/mod.rs | 4 - src/main.rs | 96 +----- 13 files changed, 498 insertions(+), 2200 deletions(-) delete mode 100644 src/channels/cli/app.rs delete mode 100644 src/channels/cli/composer.rs delete mode 100644 src/channels/cli/events.rs delete mode 100644 src/channels/cli/mod.rs delete mode 100644 src/channels/cli/model_selector.rs delete mode 100644 src/channels/cli/overlay.rs delete mode 100644 src/channels/cli/render.rs diff --git a/Cargo.lock b/Cargo.lock index 655ae87f..596e78f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,21 +663,6 @@ dependencies = [ "winx", ] -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cbc" version = "0.1.2" @@ -775,6 +760,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cobs" version = "0.3.0" @@ -790,20 +784,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "compact_str" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -819,6 +799,24 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "coolor" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3" +dependencies = [ + "crossterm 0.29.0", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -969,6 +967,54 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crokey" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c" +dependencies = [ + "crokey-proc_macros", + "crossterm 0.29.0", + "once_cell", + "serde", + "strict", +] + +[[package]] +name = "crokey-proc_macros" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231" +dependencies = [ + "crossterm 0.29.0", + "proc-macro2", + "quote", + "strict", + "syn 2.0.114", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -988,6 +1034,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1002,7 +1057,6 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.10.0", "crossterm_winapi", - "futures-core", "mio", "parking_lot", "rustix 0.38.44", @@ -1011,6 +1065,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.3", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -1040,38 +1112,14 @@ dependencies = [ "cipher", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.114", + "darling_core", + "darling_macro", ] [[package]] @@ -1088,24 +1136,13 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.114", -] - [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core 0.21.3", + "darling_core", "quote", "syn 2.0.114", ] @@ -1164,6 +1201,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", +] + [[package]] name = "diff" version = "0.1.13" @@ -1265,6 +1324,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1310,6 +1378,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "enumflags2" version = "0.7.12" @@ -1347,6 +1421,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "etcetera" version = "0.8.0" @@ -1689,8 +1769,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash", "serde", ] @@ -2045,15 +2123,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - [[package]] name = "inout" version = "0.1.4" @@ -2064,19 +2133,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instability" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" -dependencies = [ - "darling 0.20.11", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "io-extras" version = "0.18.4" @@ -2124,7 +2180,7 @@ dependencies = [ "bytes", "chrono", "clap", - "crossterm", + "crossterm 0.28.1", "deadpool-postgres", "dirs 6.0.0", "dotenvy", @@ -2138,12 +2194,12 @@ dependencies = [ "postgres-types", "pretty_assertions", "rand 0.8.5", - "ratatui", "refinery", "regex", "reqwest", "rust_decimal", "rust_decimal_macros", + "rustyline", "secrecy", "secret-service", "security-framework", @@ -2151,6 +2207,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "termimad", "testcontainers-modules", "thiserror 2.0.18", "tokio", @@ -2203,15 +2260,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.17" @@ -2258,6 +2306,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy-regex" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5c13b6857ade4c8ee05c3c3dc97d2ab5415d691213825b90d3211c425c1f907" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a95c68db5d41694cea563c86a4ba4dc02141c16ef64814108cb23def4d5438" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.114", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2317,6 +2388,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2332,15 +2409,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -2417,6 +2485,15 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimad" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b688969b16915f3ecadc7829d5b7779dee4977e503f767f34136803d5c06f" +dependencies = [ + "once_cell", +] + [[package]] name = "mio" version = "1.1.1" @@ -2429,6 +2506,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.29.0" @@ -2442,6 +2528,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2982,6 +3080,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.5" @@ -3041,27 +3149,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "ratatui" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" -dependencies = [ - "bitflags 2.10.0", - "cassowary", - "compact_str", - "crossterm", - "indoc", - "instability", - "itertools 0.13.0", - "lru", - "paste", - "strum", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", -] - [[package]] name = "rayon" version = "1.11.0" @@ -3371,6 +3458,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -3469,6 +3565,40 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "17.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.30.1", + "radix_trie", + "rustyline-derive", + "unicode-segmentation", + "unicode-width 0.2.0", + "utf8parse", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustyline-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d66de233f908aebf9cc30ac75ef9103185b4b715c6f2fb7a626aa5e5ede53ab" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "ryu" version = "1.0.22" @@ -3702,7 +3832,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", "syn 2.0.114", @@ -3840,6 +3970,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006" + [[package]] name = "stringprep" version = "0.1.5" @@ -3880,28 +4016,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.114", -] - [[package]] name = "subtle" version = "2.6.1" @@ -4000,6 +4114,22 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termimad" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "889a9370996b74cf46016ce35b96c248a9ac36d69aab1d112b3e09bc33affa49" +dependencies = [ + "coolor", + "crokey", + "crossbeam", + "lazy-regex", + "minimad", + "serde", + "thiserror 2.0.18", + "unicode-width 0.1.14", +] + [[package]] name = "testcontainers" version = "0.23.3" @@ -4507,17 +4637,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools 0.13.0", - "unicode-segmentation", - "unicode-width 0.1.14", -] - [[package]] name = "unicode-width" version = "0.1.14" @@ -4921,7 +5040,7 @@ dependencies = [ "cranelift-frontend", "cranelift-native", "gimli", - "itertools 0.12.1", + "itertools", "log", "object 0.36.7", "smallvec", @@ -5665,7 +5784,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.5", "serde", diff --git a/Cargo.toml b/Cargo.toml index 45524ee3..a9b8aceb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,9 +48,10 @@ async-trait = "0.1" # CLI clap = { version = "4", features = ["derive", "env"] } -# TUI -ratatui = "0.29" -crossterm = { version = "0.28", features = ["event-stream"] } +# Terminal +crossterm = "0.28" +rustyline = { version = "17", features = ["derive", "with-file-history"] } +termimad = "0.34" # Channel integrations axum = "0.8" diff --git a/src/channels/cli/app.rs b/src/channels/cli/app.rs deleted file mode 100644 index 8bae51bb..00000000 --- a/src/channels/cli/app.rs +++ /dev/null @@ -1,359 +0,0 @@ -//! Application state for the TUI. - -use std::collections::VecDeque; - -use crate::channels::cli::composer::ChatComposer; -use crate::channels::cli::model_selector::{ModelSelectorOverlay, ModelSelectorRequest}; -use crate::channels::cli::overlay::{ApprovalOverlay, ApprovalRequest}; - -/// Events that can occur in the TUI. -#[derive(Debug, Clone)] -pub enum AppEvent { - /// Keyboard/mouse input event. - Input(crossterm::event::Event), - /// Response from the agent. - Response(String), - /// Tool execution started. - ToolStarted { name: String }, - /// Tool execution completed. - ToolCompleted { name: String, success: bool }, - /// Request approval for a tool. - ApprovalRequested(ApprovalRequest), - /// Streaming chunk received. - StreamChunk(String), - /// Log message from the application (shown in status line). - LogMessage(String), - /// Thinking/status message (shown in chat window). - ThinkingMessage(String), - /// Error message (shown in chat window). - ErrorMessage(String), - /// Available models fetched from API. - AvailableModels(Vec), - /// Force a redraw. - Redraw, - /// Quit the application. - Quit, -} - -/// Current input mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InputMode { - /// Normal input mode. - Normal, - /// Editing input. - Editing, - /// Approval overlay is active. - Approval, - /// Model selector overlay is active. - ModelSelector, -} - -/// Message in the chat history. -#[derive(Debug, Clone)] -pub struct ChatMessage { - /// Who sent this message. - pub role: MessageRole, - /// The message content. - pub content: String, - /// Optional status indicator. - pub status: Option, -} - -/// Who sent a message. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MessageRole { - User, - Agent, - System, -} - -/// Status of a message (for in-progress indicators). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MessageStatus { - Pending, - InProgress, - Complete, - Error, -} - -impl ChatMessage { - pub fn user(content: impl Into) -> Self { - Self { - role: MessageRole::User, - content: content.into(), - status: None, - } - } - - pub fn agent(content: impl Into) -> Self { - Self { - role: MessageRole::Agent, - content: content.into(), - status: None, - } - } - - pub fn system(content: impl Into) -> Self { - Self { - role: MessageRole::System, - content: content.into(), - status: None, - } - } - - pub fn with_status(mut self, status: MessageStatus) -> Self { - self.status = Some(status); - self - } -} - -/// Application state. -pub struct AppState { - /// Current input mode. - pub mode: InputMode, - /// Chat message history. - pub messages: Vec, - /// Input composer. - pub composer: ChatComposer, - /// Approval overlay (if active). - pub approval: Option, - /// Model selector overlay (if active). - pub model_selector: Option, - /// Scroll offset for messages. - pub scroll_offset: u16, - /// Whether the app should quit. - pub should_quit: bool, - /// Pending approvals queue. - pub pending_approvals: VecDeque, - /// Current streaming response buffer. - pub streaming_buffer: Option, - /// Status line message. - pub status_message: Option, - /// Whether Ctrl+D was pressed (waiting for second press to quit). - pub ctrl_d_pending: bool, - /// Currently selected model. - pub current_model: String, - /// Available models (fetched from API). - pub available_models: Vec, -} - -impl AppState { - /// Create a new app state. - pub fn new() -> Self { - // Load saved model from settings - let settings = crate::settings::Settings::load(); - let current_model = settings.model_or("claude-3-5-sonnet-20241022"); - - Self { - mode: InputMode::Editing, - messages: vec![ChatMessage::system( - "Welcome to IronClaw. Type a message or /help for commands.", - )], - composer: ChatComposer::new(), - approval: None, - model_selector: None, - scroll_offset: 0, - should_quit: false, - pending_approvals: VecDeque::new(), - streaming_buffer: None, - status_message: None, - ctrl_d_pending: false, - current_model, - available_models: Vec::new(), - } - } - - /// Show the model selector. - pub fn show_model_selector(&mut self) { - let request = ModelSelectorRequest { - current_model: self.current_model.clone(), - available_models: self.available_models.clone(), - }; - self.model_selector = Some(ModelSelectorOverlay::new(request)); - self.mode = InputMode::ModelSelector; - } - - /// Handle model selection. - pub fn handle_model_selection(&mut self, selected: Option) { - self.model_selector = None; - self.mode = InputMode::Editing; - - if let Some(model) = selected { - if model != self.current_model { - self.current_model = model.clone(); - // Save to settings - let mut settings = crate::settings::Settings::load(); - if let Err(e) = settings.set_model(&model) { - tracing::warn!("Failed to save model setting: {}", e); - } - self.messages.push(ChatMessage::system(format!( - "Switched to model: {}", - ModelSelectorOverlay::format_model_name(&model) - ))); - } - } - } - - /// Set available models (also updates selector if open). - pub fn set_available_models(&mut self, models: Vec) { - self.available_models = models.clone(); - - // Update the selector if it's currently open - if let Some(ref mut selector) = self.model_selector { - selector.request.available_models = models; - // Reset selection index if it's out of bounds - if selector.selection_index >= selector.request.available_models.len() { - selector.selection_index = 0; - } - } - } - - /// Add a user message to history. - pub fn add_user_message(&mut self, content: impl Into) { - self.messages.push(ChatMessage::user(content)); - self.scroll_to_bottom(); - } - - /// Add an agent response to history. - pub fn add_agent_message(&mut self, content: impl Into) { - // If we were streaming, finalize it - if self.streaming_buffer.is_some() { - self.streaming_buffer = None; - } - // Remove any pending thinking message before adding the response - self.clear_thinking(); - self.messages.push(ChatMessage::agent(content)); - self.scroll_to_bottom(); - } - - /// Add an error message to the chat. - pub fn add_error_message(&mut self, content: impl Into) { - self.messages.push( - ChatMessage::system(format!("Error: {}", content.into())) - .with_status(MessageStatus::Error), - ); - self.scroll_to_bottom(); - } - - /// Add or update a thinking/status message (shown as system message). - pub fn set_thinking(&mut self, content: impl Into) { - let content = content.into(); - // Check if last message is a thinking message (system with InProgress status) - if let Some(last) = self.messages.last_mut() { - if last.role == MessageRole::System && last.status == Some(MessageStatus::InProgress) { - last.content = content; - return; - } - } - // Add new thinking message - self.messages - .push(ChatMessage::system(content).with_status(MessageStatus::InProgress)); - self.scroll_to_bottom(); - } - - /// Clear any thinking/status message. - pub fn clear_thinking(&mut self) { - // Remove any thinking messages (system with InProgress status) - self.messages.retain(|msg| { - !(msg.role == MessageRole::System && msg.status == Some(MessageStatus::InProgress)) - }); - } - - /// Start streaming a response. - pub fn start_streaming(&mut self) { - self.streaming_buffer = Some(String::new()); - self.messages - .push(ChatMessage::agent("").with_status(MessageStatus::InProgress)); - } - - /// Append to the streaming buffer. - pub fn append_stream(&mut self, chunk: &str) { - if let Some(ref mut buffer) = self.streaming_buffer { - buffer.push_str(chunk); - // Update the last message - if let Some(last) = self.messages.last_mut() { - if last.role == MessageRole::Agent { - last.content = buffer.clone(); - } - } - } - } - - /// Finalize streaming. - pub fn finish_streaming(&mut self) { - if let Some(last) = self.messages.last_mut() { - if last.role == MessageRole::Agent { - last.status = Some(MessageStatus::Complete); - } - } - self.streaming_buffer = None; - } - - /// Show an approval request. - pub fn show_approval(&mut self, request: ApprovalRequest) { - self.approval = Some(ApprovalOverlay::new(request)); - self.mode = InputMode::Approval; - } - - /// Queue an approval request. - pub fn queue_approval(&mut self, request: ApprovalRequest) { - if self.approval.is_none() { - self.show_approval(request); - } else { - self.pending_approvals.push_back(request); - } - } - - /// Handle approval response. - pub fn handle_approval_response(&mut self, approved: bool) -> Option { - let request = self.approval.take().map(|o| o.request); - - // Show next pending approval if any - if let Some(next) = self.pending_approvals.pop_front() { - self.show_approval(next); - } else { - self.mode = InputMode::Editing; - } - - if approved { request } else { None } - } - - /// Clear all pending approvals. - pub fn clear_approvals(&mut self) { - self.approval = None; - self.pending_approvals.clear(); - self.mode = InputMode::Editing; - } - - /// Set the status message. - pub fn set_status(&mut self, message: impl Into) { - self.status_message = Some(message.into()); - } - - /// Clear the status message. - pub fn clear_status(&mut self) { - self.status_message = None; - } - - /// Scroll to the bottom of messages. - pub fn scroll_to_bottom(&mut self) { - // Will be calculated based on render area in render.rs - self.scroll_offset = 0; - } - - /// Scroll up. - pub fn scroll_up(&mut self, amount: u16) { - self.scroll_offset = self.scroll_offset.saturating_add(amount); - } - - /// Scroll down. - pub fn scroll_down(&mut self, amount: u16) { - self.scroll_offset = self.scroll_offset.saturating_sub(amount); - } -} - -impl Default for AppState { - fn default() -> Self { - Self::new() - } -} diff --git a/src/channels/cli/composer.rs b/src/channels/cli/composer.rs deleted file mode 100644 index 01e87730..00000000 --- a/src/channels/cli/composer.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! Input composer with history and completion. - -use std::collections::VecDeque; - -/// Maximum number of history entries to keep. -const MAX_HISTORY: usize = 100; - -/// Available slash commands for completion. -const SLASH_COMMANDS: &[&str] = &[ - "/help", "/job", "/status", "/cancel", "/list", "/tools", "/clear", "/quit", -]; - -/// Chat input composer with history navigation and slash command completion. -pub struct ChatComposer { - /// Current input buffer. - buffer: String, - /// Cursor position in the buffer. - cursor: usize, - /// Input history. - history: VecDeque, - /// Current position in history (-1 = current input). - history_index: Option, - /// Saved current input when navigating history. - saved_input: String, - /// Completion candidates. - completions: Vec, - /// Current completion index. - completion_index: Option, -} - -impl ChatComposer { - /// Create a new composer. - pub fn new() -> Self { - Self { - buffer: String::new(), - cursor: 0, - history: VecDeque::with_capacity(MAX_HISTORY), - history_index: None, - saved_input: String::new(), - completions: Vec::new(), - completion_index: None, - } - } - - /// Get the current input buffer. - pub fn buffer(&self) -> &str { - &self.buffer - } - - /// Get the cursor position. - pub fn cursor(&self) -> usize { - self.cursor - } - - /// Check if the buffer is empty. - pub fn is_empty(&self) -> bool { - self.buffer.is_empty() - } - - /// Insert a character at the cursor. - pub fn insert(&mut self, c: char) { - self.clear_completion(); - self.buffer.insert(self.cursor, c); - self.cursor += c.len_utf8(); - } - - /// Insert a string at the cursor. - pub fn insert_str(&mut self, s: &str) { - self.clear_completion(); - self.buffer.insert_str(self.cursor, s); - self.cursor += s.len(); - } - - /// Delete the character before the cursor (backspace). - pub fn backspace(&mut self) { - self.clear_completion(); - if self.cursor > 0 { - // Find the previous character boundary - let prev = self.buffer[..self.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - self.buffer.drain(prev..self.cursor); - self.cursor = prev; - } - } - - /// Delete the character at the cursor (delete). - pub fn delete(&mut self) { - self.clear_completion(); - if self.cursor < self.buffer.len() { - // Find the next character boundary - let next = self.buffer[self.cursor..] - .char_indices() - .nth(1) - .map(|(i, _)| self.cursor + i) - .unwrap_or(self.buffer.len()); - self.buffer.drain(self.cursor..next); - } - } - - /// Move cursor left. - pub fn move_left(&mut self) { - if self.cursor > 0 { - self.cursor = self.buffer[..self.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - } - } - - /// Move cursor right. - pub fn move_right(&mut self) { - if self.cursor < self.buffer.len() { - self.cursor = self.buffer[self.cursor..] - .char_indices() - .nth(1) - .map(|(i, _)| self.cursor + i) - .unwrap_or(self.buffer.len()); - } - } - - /// Move cursor to start. - pub fn move_home(&mut self) { - self.cursor = 0; - } - - /// Move cursor to end. - pub fn move_end(&mut self) { - self.cursor = self.buffer.len(); - } - - /// Delete from cursor to end of line. - pub fn kill_line(&mut self) { - self.clear_completion(); - self.buffer.truncate(self.cursor); - } - - /// Delete from start to cursor. - pub fn kill_to_start(&mut self) { - self.clear_completion(); - self.buffer.drain(..self.cursor); - self.cursor = 0; - } - - /// Clear the entire buffer. - pub fn clear(&mut self) { - self.buffer.clear(); - self.cursor = 0; - self.clear_completion(); - } - - /// Submit the current input and return it. - pub fn submit(&mut self) -> String { - let input = std::mem::take(&mut self.buffer); - self.cursor = 0; - self.clear_completion(); - - // Add to history if non-empty and different from last entry - if !input.is_empty() && self.history.front() != Some(&input) { - self.history.push_front(input.clone()); - if self.history.len() > MAX_HISTORY { - self.history.pop_back(); - } - } - - self.history_index = None; - self.saved_input.clear(); - - input - } - - /// Navigate to previous history entry. - pub fn history_prev(&mut self) { - if self.history.is_empty() { - return; - } - - match self.history_index { - None => { - // Save current input and go to first history entry - self.saved_input = std::mem::take(&mut self.buffer); - self.history_index = Some(0); - self.buffer = self.history[0].clone(); - } - Some(i) if i + 1 < self.history.len() => { - self.history_index = Some(i + 1); - self.buffer = self.history[i + 1].clone(); - } - _ => {} - } - - self.cursor = self.buffer.len(); - self.clear_completion(); - } - - /// Navigate to next history entry. - pub fn history_next(&mut self) { - match self.history_index { - Some(0) => { - // Go back to saved input - self.history_index = None; - self.buffer = std::mem::take(&mut self.saved_input); - } - Some(i) => { - self.history_index = Some(i - 1); - self.buffer = self.history[i - 1].clone(); - } - None => {} - } - - self.cursor = self.buffer.len(); - self.clear_completion(); - } - - /// Attempt tab completion. - pub fn complete(&mut self) { - // Only complete slash commands for now - if !self.buffer.starts_with('/') { - return; - } - - if self.completions.is_empty() { - // Generate completions - let prefix = &self.buffer; - self.completions = SLASH_COMMANDS - .iter() - .filter(|cmd| cmd.starts_with(prefix)) - .map(|s| s.to_string()) - .collect(); - - if !self.completions.is_empty() { - self.completion_index = Some(0); - } - } else if let Some(i) = self.completion_index { - // Cycle through completions - self.completion_index = Some((i + 1) % self.completions.len()); - } - - // Apply completion - if let Some(i) = self.completion_index { - if let Some(completion) = self.completions.get(i) { - self.buffer = completion.clone(); - self.cursor = self.buffer.len(); - } - } - } - - /// Clear completion state. - fn clear_completion(&mut self) { - self.completions.clear(); - self.completion_index = None; - } - - /// Get current completion hint (for display). - pub fn completion_hint(&self) -> Option<&str> { - if let Some(i) = self.completion_index { - self.completions.get(i).map(|s| s.as_str()) - } else { - None - } - } - - /// Get the number of completions available. - pub fn completion_count(&self) -> usize { - self.completions.len() - } -} - -impl Default for ChatComposer { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_insert_and_backspace() { - let mut composer = ChatComposer::new(); - composer.insert('h'); - composer.insert('i'); - assert_eq!(composer.buffer(), "hi"); - composer.backspace(); - assert_eq!(composer.buffer(), "h"); - } - - #[test] - fn test_history_navigation() { - let mut composer = ChatComposer::new(); - composer.insert_str("first"); - composer.submit(); - composer.insert_str("second"); - composer.submit(); - - composer.insert_str("current"); - composer.history_prev(); - assert_eq!(composer.buffer(), "second"); - composer.history_prev(); - assert_eq!(composer.buffer(), "first"); - composer.history_next(); - assert_eq!(composer.buffer(), "second"); - composer.history_next(); - assert_eq!(composer.buffer(), "current"); - } - - #[test] - fn test_completion() { - let mut composer = ChatComposer::new(); - composer.insert_str("/hel"); - composer.complete(); - assert_eq!(composer.buffer(), "/help"); - } -} diff --git a/src/channels/cli/events.rs b/src/channels/cli/events.rs deleted file mode 100644 index 680a056b..00000000 --- a/src/channels/cli/events.rs +++ /dev/null @@ -1,333 +0,0 @@ -//! Event handling for the TUI. - -use std::io; -use std::time::Duration; - -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use tokio::sync::mpsc; - -use crate::channels::IncomingMessage; -use crate::channels::cli::app::{AppEvent, AppState, InputMode}; -use crate::channels::cli::render; - -/// Tick rate for the event loop (50ms = 20fps). -const TICK_RATE: Duration = Duration::from_millis(50); - -/// Run the main event loop. -pub fn run_event_loop( - terminal: &mut Terminal>, - app: &mut AppState, - msg_tx: mpsc::Sender, - mut event_rx: mpsc::Receiver, -) -> io::Result<()> { - loop { - // Render - terminal.draw(|f| render::render(f, app))?; - - // Check for quit - send shutdown signal and exit - if app.should_quit { - // Send a shutdown message so the agent loop knows to exit - let shutdown_msg = IncomingMessage::new("tui", "system", "/shutdown"); - let _ = msg_tx.blocking_send(shutdown_msg); - // Explicitly drop to close the channel - drop(msg_tx); - return Ok(()); - } - - // Poll for terminal events - if event::poll(TICK_RATE)? { - let evt = event::read()?; - if let Err(e) = handle_event(app, evt, &msg_tx) { - tracing::error!("Event handling error: {}", e); - } - } - - // Check for app events from agent (non-blocking) - while let Ok(app_event) = event_rx.try_recv() { - handle_app_event(app, app_event); - } - } -} - -/// Handle a crossterm event. -fn handle_event( - app: &mut AppState, - event: Event, - msg_tx: &mpsc::Sender, -) -> io::Result<()> { - match event { - Event::Key(key) => handle_key(app, key, msg_tx), - Event::Mouse(_) => Ok(()), // Could handle mouse scrolling here - Event::Resize(_, _) => Ok(()), // Terminal will handle resize - _ => Ok(()), - } -} - -/// Handle a key event. -fn handle_key( - app: &mut AppState, - key: KeyEvent, - msg_tx: &mpsc::Sender, -) -> io::Result<()> { - // Global keybindings - if key.modifiers.contains(KeyModifiers::CONTROL) { - match key.code { - KeyCode::Char('c') => { - if app.mode == InputMode::Approval { - // Cancel all pending approvals - app.clear_approvals(); - } else { - // Quit - app.should_quit = true; - } - app.ctrl_d_pending = false; - return Ok(()); - } - KeyCode::Char('d') => { - if app.ctrl_d_pending { - // Second Ctrl+D, quit now - app.should_quit = true; - } else { - // First Ctrl+D, show hint - app.ctrl_d_pending = true; - app.set_status("Press Ctrl+D again to quit"); - } - return Ok(()); - } - _ => { - // Any other Ctrl+ combo clears the Ctrl+D pending state - app.ctrl_d_pending = false; - } - } - } else { - // Any non-Ctrl key clears the Ctrl+D pending state - app.ctrl_d_pending = false; - } - - match app.mode { - InputMode::Normal => handle_normal_mode(app, key), - InputMode::Editing => handle_editing_mode(app, key, msg_tx), - InputMode::Approval => handle_approval_mode(app, key), - InputMode::ModelSelector => handle_model_selector_mode(app, key), - } -} - -/// Handle keys in normal mode. -fn handle_normal_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> { - match key.code { - KeyCode::Char('i') | KeyCode::Char('a') => { - app.mode = InputMode::Editing; - } - KeyCode::Char('q') => { - app.should_quit = true; - } - KeyCode::Up | KeyCode::Char('k') => { - app.scroll_up(1); - } - KeyCode::Down | KeyCode::Char('j') => { - app.scroll_down(1); - } - KeyCode::PageUp => { - app.scroll_up(10); - } - KeyCode::PageDown => { - app.scroll_down(10); - } - KeyCode::Char('G') => { - app.scroll_to_bottom(); - } - _ => {} - } - Ok(()) -} - -/// Handle keys in editing mode. -fn handle_editing_mode( - app: &mut AppState, - key: KeyEvent, - msg_tx: &mpsc::Sender, -) -> io::Result<()> { - match key.code { - KeyCode::Enter => { - if !app.composer.is_empty() { - let input = app.composer.submit(); - - // Handle /model command locally (TUI-specific) - if input.trim().eq_ignore_ascii_case("/model") { - app.show_model_selector(); - return Ok(()); - } - - app.add_user_message(&input); - - // Send message to agent - let msg = IncomingMessage::new("tui", "local-user", &input); - let _ = msg_tx.blocking_send(msg); - } - } - KeyCode::Esc => { - app.mode = InputMode::Normal; - } - KeyCode::Backspace => { - app.composer.backspace(); - } - KeyCode::Delete => { - app.composer.delete(); - } - KeyCode::Left => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - // Move word left (simplified: just move to start) - app.composer.move_home(); - } else { - app.composer.move_left(); - } - } - KeyCode::Right => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - // Move word right (simplified: just move to end) - app.composer.move_end(); - } else { - app.composer.move_right(); - } - } - KeyCode::Home => { - app.composer.move_home(); - } - KeyCode::End => { - app.composer.move_end(); - } - KeyCode::Up => { - app.composer.history_prev(); - } - KeyCode::Down => { - app.composer.history_next(); - } - KeyCode::Tab => { - app.composer.complete(); - } - KeyCode::Char(c) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - match c { - 'a' => app.composer.move_home(), - 'e' => app.composer.move_end(), - 'k' => app.composer.kill_line(), - 'u' => app.composer.kill_to_start(), - 'w' => { - // Delete word backwards (simplified: clear) - app.composer.clear(); - } - _ => {} - } - } else { - app.composer.insert(c); - } - } - _ => {} - } - Ok(()) -} - -/// Handle keys in approval mode. -fn handle_approval_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> { - if let Some(ref mut overlay) = app.approval { - match key.code { - KeyCode::Left | KeyCode::Char('h') => { - overlay.select_prev(); - } - KeyCode::Right | KeyCode::Char('l') => { - overlay.select_next(); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let (approved, _always) = overlay.confirm(); - app.handle_approval_response(approved); - // TODO: If always, remember to auto-approve this tool - } - KeyCode::Char(c) => { - if let Some(approved) = overlay.handle_shortcut(c) { - app.handle_approval_response(approved); - } - } - KeyCode::Esc => { - // Deny this approval - app.handle_approval_response(false); - } - _ => {} - } - } - Ok(()) -} - -/// Handle keys in model selector mode. -fn handle_model_selector_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> { - if let Some(ref mut overlay) = app.model_selector { - match key.code { - KeyCode::Left | KeyCode::Char('h') => { - overlay.select_prev(); - } - KeyCode::Right | KeyCode::Char('l') => { - overlay.select_next(); - } - KeyCode::Enter | KeyCode::Char(' ') => { - let selected = overlay.selected_model().map(|s| s.to_string()); - app.handle_model_selection(selected); - } - KeyCode::Esc => { - // Cancel without changing model - app.handle_model_selection(None); - } - _ => {} - } - } - Ok(()) -} - -/// Handle an application event. -fn handle_app_event(app: &mut AppState, event: AppEvent) { - match event { - AppEvent::Response(content) => { - app.add_agent_message(content); - } - AppEvent::ToolStarted { name } => { - app.set_thinking(format!("⚙️ Running tool: {}...", name)); - } - AppEvent::ToolCompleted { name, success } => { - if success { - app.set_thinking(format!("✓ Tool {} completed", name)); - } else { - app.set_thinking(format!("✗ Tool {} failed", name)); - } - } - AppEvent::ApprovalRequested(request) => { - app.queue_approval(request); - } - AppEvent::StreamChunk(chunk) => { - if app.streaming_buffer.is_none() { - app.start_streaming(); - } - app.append_stream(&chunk); - } - AppEvent::Redraw => { - // Just triggers a redraw on next loop iteration - } - AppEvent::Quit => { - app.should_quit = true; - } - AppEvent::Input(_) => { - // Already handled directly - } - AppEvent::LogMessage(msg) => { - app.set_status(msg); - } - AppEvent::ThinkingMessage(msg) => { - app.set_thinking(msg); - } - AppEvent::ErrorMessage(msg) => { - app.add_error_message(msg); - } - AppEvent::AvailableModels(models) => { - app.set_available_models(models); - } - } -} diff --git a/src/channels/cli/mod.rs b/src/channels/cli/mod.rs deleted file mode 100644 index afeb590e..00000000 --- a/src/channels/cli/mod.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Interactive TUI channel using Ratatui. -//! -//! Provides a rich terminal interface with: -//! - Input history navigation -//! - Slash command completion -//! - Approval overlays for tool execution -//! - Streaming response display - -mod app; -mod composer; -mod events; -mod model_selector; -mod overlay; -mod render; - -use std::io; -use std::sync::Arc; - -use async_trait::async_trait; -use crossterm::{ - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use tokio::sync::{Mutex, mpsc}; -use tokio_stream::wrappers::ReceiverStream; - -use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; -use crate::error::ChannelError; - -pub use app::{AppEvent, AppState, InputMode}; -pub use composer::ChatComposer; -pub use model_selector::{ModelSelectorOverlay, ModelSelectorRequest}; -pub use overlay::{ApprovalOverlay, ApprovalRequest}; - -/// TUI channel for interactive terminal input with Ratatui. -pub struct TuiChannel { - /// Channel for sending events to the TUI (created upfront for logging). - event_tx: mpsc::Sender, - /// Receiver end, taken when start() is called. - event_rx: Arc>>>, -} - -impl TuiChannel { - /// Create a new TUI channel. - pub fn new() -> Self { - let (event_tx, event_rx) = mpsc::channel(64); - Self { - event_tx, - event_rx: Arc::new(Mutex::new(Some(event_rx))), - } - } - - /// Get a log writer that sends messages to the TUI status line. - /// Use this to redirect tracing output to the TUI. - pub fn log_writer(&self) -> TuiLogWriter { - TuiLogWriter::new(self.event_tx.clone()) - } - - /// Get a sender for sending events to the TUI. - /// Use this to send available models or other events from outside the channel. - pub fn event_sender(&self) -> mpsc::Sender { - self.event_tx.clone() - } -} - -impl Default for TuiChannel { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl Channel for TuiChannel { - fn name(&self) -> &str { - "tui" - } - - async fn start(&self) -> Result { - let (msg_tx, msg_rx) = mpsc::channel(32); - - // Take the event receiver (can only start once) - let event_rx = { - let mut guard = self.event_rx.lock().await; - guard.take().ok_or_else(|| ChannelError::StartupFailed { - name: "tui".to_string(), - reason: "TUI channel already started".to_string(), - })? - }; - - tokio::task::spawn_blocking(move || { - if let Err(e) = run_tui(msg_tx, event_rx) { - // Try to restore terminal even on error - let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen); - eprintln!("TUI error: {}", e); - } - }); - - Ok(Box::pin(ReceiverStream::new(msg_rx))) - } - - async fn respond( - &self, - _msg: &IncomingMessage, - response: OutgoingResponse, - ) -> Result<(), ChannelError> { - self.event_tx - .send(AppEvent::Response(response.content)) - .await - .map_err(|e| ChannelError::SendFailed { - name: "tui".to_string(), - reason: e.to_string(), - })?; - Ok(()) - } - - async fn send_status( - &self, - status: StatusUpdate, - _metadata: &serde_json::Value, - ) -> Result<(), ChannelError> { - let event = match status { - StatusUpdate::Thinking(msg) => AppEvent::ThinkingMessage(format!("🤔 {}", msg)), - StatusUpdate::ToolStarted { name } => AppEvent::ToolStarted { name }, - StatusUpdate::ToolCompleted { name, success } => { - AppEvent::ToolCompleted { name, success } - } - StatusUpdate::StreamChunk(chunk) => AppEvent::StreamChunk(chunk), - StatusUpdate::Status(msg) => AppEvent::ThinkingMessage(msg), - }; - self.event_tx - .send(event) - .await - .map_err(|e| ChannelError::SendFailed { - name: "tui".to_string(), - reason: e.to_string(), - })?; - Ok(()) - } - - async fn broadcast( - &self, - _user_id: &str, - response: OutgoingResponse, - ) -> Result<(), ChannelError> { - // For TUI, broadcasts appear as regular agent responses with a notification indicator - self.event_tx - .send(AppEvent::Response(response.content)) - .await - .map_err(|e| ChannelError::SendFailed { - name: "tui".to_string(), - reason: e.to_string(), - })?; - Ok(()) - } - - async fn health_check(&self) -> Result<(), ChannelError> { - // Channel is healthy if we haven't been closed - if self.event_tx.is_closed() { - Err(ChannelError::HealthCheckFailed { - name: "tui".to_string(), - }) - } else { - Ok(()) - } - } - - async fn shutdown(&self) -> Result<(), ChannelError> { - let _ = self.event_tx.send(AppEvent::Quit).await; - Ok(()) - } -} - -/// Run the TUI event loop (blocking). -fn run_tui( - msg_tx: mpsc::Sender, - event_rx: mpsc::Receiver, -) -> io::Result<()> { - // Setup terminal - // Note: We don't enable mouse capture so users can select text normally - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Create app state - let mut app = AppState::new(); - - // Run event loop - let result = events::run_event_loop(&mut terminal, &mut app, msg_tx, event_rx); - - // Restore terminal - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - - result -} - -/// TUI-compatible tracing writer that sends log messages to the TUI status line. -#[derive(Clone)] -pub struct TuiLogWriter { - tx: mpsc::Sender, -} - -impl TuiLogWriter { - pub fn new(tx: mpsc::Sender) -> Self { - Self { tx } - } -} - -impl std::io::Write for TuiLogWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - if let Ok(s) = std::str::from_utf8(buf) { - let s = s.trim(); - if !s.is_empty() { - // Fire and forget - don't block on logging - let _ = self.tx.try_send(AppEvent::LogMessage(s.to_string())); - } - } - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TuiLogWriter { - type Writer = Self; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } -} diff --git a/src/channels/cli/model_selector.rs b/src/channels/cli/model_selector.rs deleted file mode 100644 index 23e7d477..00000000 --- a/src/channels/cli/model_selector.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Model selector overlay for switching LLM models. - -/// Request to show the model selector. -#[derive(Debug, Clone)] -pub struct ModelSelectorRequest { - /// Currently selected model. - pub current_model: String, - /// Available models to choose from. - pub available_models: Vec, -} - -/// Model selector overlay state. -#[derive(Debug, Clone)] -pub struct ModelSelectorOverlay { - /// The request that triggered this overlay. - pub request: ModelSelectorRequest, - /// Currently highlighted index. - pub selection_index: usize, -} - -impl ModelSelectorOverlay { - /// Create a new model selector overlay. - pub fn new(request: ModelSelectorRequest) -> Self { - // Find the current model in the list, default to 0 - let selection_index = request - .available_models - .iter() - .position(|m| m == &request.current_model) - .unwrap_or(0); - - Self { - request, - selection_index, - } - } - - /// Get the list of available models. - pub fn models(&self) -> &[String] { - &self.request.available_models - } - - /// Move selection up. - pub fn select_prev(&mut self) { - let len = self.request.available_models.len(); - if len == 0 { - return; - } - if self.selection_index > 0 { - self.selection_index -= 1; - } else { - // Wrap to bottom - self.selection_index = len - 1; - } - } - - /// Move selection down. - pub fn select_next(&mut self) { - let len = self.request.available_models.len(); - if len == 0 { - return; - } - if self.selection_index < len - 1 { - self.selection_index += 1; - } else { - // Wrap to top - self.selection_index = 0; - } - } - - /// Get the currently selected model name. - pub fn selected_model(&self) -> Option<&str> { - self.request - .available_models - .get(self.selection_index) - .map(|s| s.as_str()) - } - - /// Check if the selection is the current model. - pub fn is_current(&self) -> bool { - self.selected_model() == Some(&self.request.current_model) - } - - /// Format a model name for display (shorten long names). - pub fn format_model_name(model: &str) -> String { - // Shorten fireworks model names - if let Some(rest) = model.strip_prefix("fireworks::accounts/fireworks/models/") { - return format!("fireworks/{}", rest); - } - // Shorten other long prefixes - if let Some(rest) = model.strip_prefix("accounts/") { - return rest.to_string(); - } - model.to_string() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_model_selector_navigation() { - let request = ModelSelectorRequest { - current_model: "gpt-4o".to_string(), - available_models: vec![ - "claude-3-5-sonnet".to_string(), - "gpt-4o".to_string(), - "gpt-4o-mini".to_string(), - ], - }; - let mut overlay = ModelSelectorOverlay::new(request); - - // Should start at gpt-4o index (1) - assert_eq!(overlay.selected_model(), Some("gpt-4o")); - - // Navigate down - overlay.select_next(); - assert_eq!(overlay.selected_model(), Some("gpt-4o-mini")); - - // Navigate down (wrap) - overlay.select_next(); - assert_eq!(overlay.selected_model(), Some("claude-3-5-sonnet")); - - // Navigate up - overlay.select_prev(); - assert_eq!(overlay.selected_model(), Some("gpt-4o-mini")); - } - - #[test] - fn test_format_model_name() { - assert_eq!( - ModelSelectorOverlay::format_model_name("claude-3-5-sonnet-20241022"), - "claude-3-5-sonnet-20241022" - ); - assert_eq!( - ModelSelectorOverlay::format_model_name( - "fireworks::accounts/fireworks/models/llama-v3p1-405b-instruct" - ), - "fireworks/llama-v3p1-405b-instruct" - ); - } - - #[test] - fn test_empty_models() { - let request = ModelSelectorRequest { - current_model: "unknown".to_string(), - available_models: vec![], - }; - let mut overlay = ModelSelectorOverlay::new(request); - assert_eq!(overlay.selected_model(), None); - - // Should not panic - overlay.select_next(); - overlay.select_prev(); - } -} diff --git a/src/channels/cli/overlay.rs b/src/channels/cli/overlay.rs deleted file mode 100644 index 1a8b4d97..00000000 --- a/src/channels/cli/overlay.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Approval overlay modal. - -use uuid::Uuid; - -/// A request for user approval before executing a tool. -#[derive(Debug, Clone)] -pub struct ApprovalRequest { - /// Unique ID for this request. - pub id: Uuid, - /// Name of the tool requesting approval. - pub tool_name: String, - /// Description of what the tool will do. - pub description: String, - /// Parameters being passed to the tool. - pub parameters: serde_json::Value, - /// Whether this is a destructive operation. - pub destructive: bool, -} - -impl ApprovalRequest { - /// Create a new approval request. - pub fn new( - tool_name: impl Into, - description: impl Into, - parameters: serde_json::Value, - ) -> Self { - Self { - id: Uuid::new_v4(), - tool_name: tool_name.into(), - description: description.into(), - parameters, - destructive: false, - } - } - - /// Mark as destructive operation. - pub fn destructive(mut self) -> Self { - self.destructive = true; - self - } -} - -/// Current selection in the approval overlay. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApprovalSelection { - /// Yes, approve this action. - Yes, - /// No, deny this action. - No, - /// Always approve this tool (for this session). - Always, -} - -impl ApprovalSelection { - /// Get the next selection (cycling). - pub fn next(self) -> Self { - match self { - Self::Yes => Self::No, - Self::No => Self::Always, - Self::Always => Self::Yes, - } - } - - /// Get the previous selection (cycling). - pub fn prev(self) -> Self { - match self { - Self::Yes => Self::Always, - Self::No => Self::Yes, - Self::Always => Self::No, - } - } -} - -/// Approval overlay state. -pub struct ApprovalOverlay { - /// The request being shown. - pub request: ApprovalRequest, - /// Current selection. - pub selection: ApprovalSelection, -} - -impl ApprovalOverlay { - /// Create a new approval overlay. - pub fn new(request: ApprovalRequest) -> Self { - Self { - request, - selection: ApprovalSelection::Yes, - } - } - - /// Move selection left. - pub fn select_prev(&mut self) { - self.selection = self.selection.prev(); - } - - /// Move selection right. - pub fn select_next(&mut self) { - self.selection = self.selection.next(); - } - - /// Handle keyboard shortcut. - pub fn handle_shortcut(&mut self, c: char) -> Option { - match c.to_ascii_lowercase() { - 'y' => Some(true), - 'n' => Some(false), - 'a' => { - self.selection = ApprovalSelection::Always; - Some(true) - } - _ => None, - } - } - - /// Confirm the current selection. - pub fn confirm(&self) -> (bool, bool) { - match self.selection { - ApprovalSelection::Yes => (true, false), - ApprovalSelection::No => (false, false), - ApprovalSelection::Always => (true, true), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_approval_selection_cycle() { - let sel = ApprovalSelection::Yes; - assert_eq!(sel.next(), ApprovalSelection::No); - assert_eq!(sel.next().next(), ApprovalSelection::Always); - assert_eq!(sel.next().next().next(), ApprovalSelection::Yes); - } - - #[test] - fn test_approval_shortcuts() { - let request = ApprovalRequest::new("test", "Test operation", serde_json::json!({})); - let mut overlay = ApprovalOverlay::new(request); - - assert_eq!(overlay.handle_shortcut('y'), Some(true)); - assert_eq!(overlay.handle_shortcut('n'), Some(false)); - assert_eq!(overlay.handle_shortcut('x'), None); - } -} diff --git a/src/channels/cli/render.rs b/src/channels/cli/render.rs deleted file mode 100644 index ba692260..00000000 --- a/src/channels/cli/render.rs +++ /dev/null @@ -1,341 +0,0 @@ -//! TUI rendering with Ratatui. - -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, -}; - -use crate::channels::cli::app::{AppState, InputMode, MessageRole, MessageStatus}; -use crate::channels::cli::model_selector::ModelSelectorOverlay; -use crate::channels::cli::overlay::ApprovalSelection; - -/// Render the entire UI. -pub fn render(frame: &mut Frame, app: &AppState) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(3), // Messages - Constraint::Length(3), // Input - Constraint::Length(1), // Status - ]) - .split(frame.area()); - - render_messages(frame, app, chunks[0]); - render_input(frame, app, chunks[1]); - render_status(frame, app, chunks[2]); - - // Render approval overlay if active - if app.mode == InputMode::Approval { - render_approval_overlay(frame, app); - } -} - -/// Render the message history. -fn render_messages(frame: &mut Frame, app: &AppState, area: Rect) { - // Build all lines from all messages - let mut lines: Vec = Vec::new(); - - for msg in &app.messages { - let (prefix, style) = match msg.role { - MessageRole::User => ("You: ", Style::default().fg(Color::Cyan)), - MessageRole::Agent => ("Agent: ", Style::default().fg(Color::Green)), - MessageRole::System => ( - "", - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - ), - }; - - let status_indicator = match msg.status { - Some(MessageStatus::Pending) => " ⏳", - Some(MessageStatus::InProgress) => " ⚙️", - Some(MessageStatus::Complete) => " ✓", - Some(MessageStatus::Error) => " ✗", - None => "", - }; - - // Split content by newlines and create a line for each - let content_lines: Vec<&str> = msg.content.lines().collect(); - for (i, line_text) in content_lines.iter().enumerate() { - if i == 0 { - // First line gets the prefix - let line_content = if status_indicator.is_empty() { - format!("{}{}", prefix, line_text) - } else if content_lines.len() == 1 { - format!("{}{}{}", prefix, line_text, status_indicator) - } else { - format!("{}{}", prefix, line_text) - }; - lines.push(Line::styled(line_content, style)); - } else if i == content_lines.len() - 1 && !status_indicator.is_empty() { - // Last line gets status indicator - lines.push(Line::styled( - format!("{}{}", line_text, status_indicator), - style, - )); - } else { - // Middle lines just get the content - lines.push(Line::styled(line_text.to_string(), style)); - } - } - - // Add empty line between messages for readability - lines.push(Line::from("")); - } - - // Calculate scroll - show most recent messages - let visible_height = area.height.saturating_sub(2) as usize; // Account for borders - let total_lines = lines.len(); - let scroll_offset = total_lines.saturating_sub(visible_height); - - let text = Text::from(lines); - let messages = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL).title("Chat")) - .wrap(Wrap { trim: false }) - .scroll((scroll_offset as u16, 0)); - - frame.render_widget(messages, area); -} - -/// Render the input area (or model selector when in ModelSelector mode). -fn render_input(frame: &mut Frame, app: &AppState, area: Rect) { - // In ModelSelector mode, render inline selector instead of input - if app.mode == InputMode::ModelSelector { - render_model_selector_inline(frame, app, area); - return; - } - - let input_style = match app.mode { - InputMode::Editing => Style::default().fg(Color::Yellow), - InputMode::Normal => Style::default(), - InputMode::Approval | InputMode::ModelSelector => Style::default().fg(Color::DarkGray), - }; - - let buffer = app.composer.buffer(); - let cursor = app.composer.cursor(); - - // Build the input text with cursor - let (before, after) = buffer.split_at(cursor.min(buffer.len())); - let cursor_char = after.chars().next().unwrap_or(' '); - let after_cursor = if after.is_empty() { - "" - } else { - &after[cursor_char.len_utf8()..] - }; - - let input = Paragraph::new(Line::from(vec![ - Span::raw(before), - Span::styled( - cursor_char.to_string(), - Style::default().bg(Color::White).fg(Color::Black), - ), - Span::raw(after_cursor), - ])) - .style(input_style) - .block(Block::default().borders(Borders::ALL).title("Input")); - - frame.render_widget(input, area); - - // Show cursor in editing mode - if app.mode == InputMode::Editing { - // Calculate cursor position accounting for the block border - let cursor_x = area.x + 1 + cursor as u16; - let cursor_y = area.y + 1; - frame.set_cursor_position((cursor_x, cursor_y)); - } -} - -/// Render inline model selector in the input area. -fn render_model_selector_inline(frame: &mut Frame, app: &AppState, area: Rect) { - let Some(ref overlay) = app.model_selector else { - return; - }; - - let models = overlay.models(); - - // Build horizontal list of models - let mut spans: Vec = Vec::new(); - - if models.is_empty() { - spans.push(Span::styled( - "Loading models...", - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - )); - } else { - for (i, model) in models.iter().enumerate() { - if i > 0 { - spans.push(Span::raw(" ")); - } - - let display_name = ModelSelectorOverlay::format_model_name(model); - let is_selected = i == overlay.selection_index; - let is_current = model == &overlay.request.current_model; - - let style = if is_selected { - Style::default().bg(Color::Blue).fg(Color::White) - } else if is_current { - Style::default().fg(Color::Green) - } else { - Style::default().fg(Color::White) - }; - - let prefix = if is_current { "●" } else { " " }; - spans.push(Span::styled(format!("{}{}", prefix, display_name), style)); - } - } - - let content = Paragraph::new(Line::from(spans)) - .block(Block::default().borders(Borders::ALL).title(Span::styled( - "Select Model", - Style::default().fg(Color::Cyan), - ))) - .scroll(( - 0, - calculate_model_scroll(overlay, area.width.saturating_sub(2)), - )); - - frame.render_widget(content, area); -} - -/// Calculate horizontal scroll offset to keep selected model visible. -fn calculate_model_scroll(overlay: &ModelSelectorOverlay, visible_width: u16) -> u16 { - let models = overlay.models(); - if models.is_empty() { - return 0; - } - - // Estimate position of selected model (rough calculation) - let mut pos: u16 = 0; - for (i, model) in models.iter().enumerate() { - let name_len = ModelSelectorOverlay::format_model_name(model).len() as u16 + 3; // +3 for prefix and spacing - if i == overlay.selection_index { - // Check if selection is beyond visible area - if pos > visible_width { - return pos.saturating_sub(visible_width / 2); - } - return 0; - } - pos += name_len; - } - 0 -} - -/// Render the status line. -fn render_status(frame: &mut Frame, app: &AppState, area: Rect) { - let status_text = if let Some(ref msg) = app.status_message { - msg.clone() - } else { - match app.mode { - InputMode::Normal | InputMode::Editing => { - let model = ModelSelectorOverlay::format_model_name(&app.current_model); - format!("{} | /model to switch", model) - } - InputMode::Approval => "y=Yes, n=No, a=Always, Ctrl+C=Cancel".to_string(), - InputMode::ModelSelector => "←/→ navigate, Enter=select, Esc=cancel".to_string(), - } - }; - - let status = Paragraph::new(status_text).style(Style::default().fg(Color::DarkGray)); - - frame.render_widget(status, area); -} - -/// Render the approval overlay. -fn render_approval_overlay(frame: &mut Frame, app: &AppState) { - let Some(ref overlay) = app.approval else { - return; - }; - - let area = frame.area(); - - // Calculate overlay size and position - let overlay_width = (area.width * 60 / 100).min(60); - let overlay_height = 12; - let overlay_x = (area.width - overlay_width) / 2; - let overlay_y = (area.height - overlay_height) / 2; - - let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height); - - // Clear the area behind the overlay - frame.render_widget(Clear, overlay_area); - - // Build overlay content - let title = if overlay.request.destructive { - "⚠️ Approval Required (Destructive)" - } else { - "Approval Required" - }; - - let title_style = if overlay.request.destructive { - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - }; - - // Build the text content - let mut lines = vec![ - Line::from(vec![ - Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(&overlay.request.tool_name), - ]), - Line::from(""), - Line::from(overlay.request.description.as_str()), - Line::from(""), - ]; - - // Add parameters preview (truncated) - let params_str = serde_json::to_string_pretty(&overlay.request.parameters) - .unwrap_or_else(|_| "{}".to_string()); - let params_preview: String = params_str.chars().take(100).collect(); - lines.push(Line::from(vec![ - Span::styled("Params: ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled(params_preview, Style::default().fg(Color::DarkGray)), - ])); - lines.push(Line::from("")); - - // Add selection buttons - let yes_style = if overlay.selection == ApprovalSelection::Yes { - Style::default().bg(Color::Green).fg(Color::Black) - } else { - Style::default().fg(Color::Green) - }; - - let no_style = if overlay.selection == ApprovalSelection::No { - Style::default().bg(Color::Red).fg(Color::Black) - } else { - Style::default().fg(Color::Red) - }; - - let always_style = if overlay.selection == ApprovalSelection::Always { - Style::default().bg(Color::Blue).fg(Color::Black) - } else { - Style::default().fg(Color::Blue) - }; - - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(" [Y]es ", yes_style), - Span::raw(" "), - Span::styled(" [N]o ", no_style), - Span::raw(" "), - Span::styled(" [A]lways ", always_style), - ])); - - let content = Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .title(Span::styled(title, title_style)), - ) - .wrap(Wrap { trim: true }); - - frame.render_widget(content, overlay_area); -} diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 550707d1..fc85436e 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -9,9 +9,9 @@ //! ┌─────────────────────────────────────────────────────────────────────┐ //! │ ChannelManager │ //! │ │ -//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -//! │ │ TuiChannel │ │ HttpChannel │ │ WasmChannel │ ... │ -//! │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +//! │ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ │ +//! │ │ ReplChannel │ │ HttpChannel │ │ WasmChannel │ ... │ +//! │ └──────┬───────┘ └──────┬──────┘ └──────┬──────┘ │ //! │ │ │ │ │ //! │ └─────────────────┴─────────────────┘ │ //! │ │ │ @@ -28,14 +28,12 @@ //! See the [`wasm`] module for details. mod channel; -pub mod cli; mod http; mod manager; mod repl; pub mod wasm; pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; -pub use cli::{AppEvent, TuiChannel}; pub use http::HttpChannel; pub use manager::ChannelManager; pub use repl::ReplChannel; diff --git a/src/channels/repl.rs b/src/channels/repl.rs index a498ff92..35fdcb07 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -1,6 +1,8 @@ -//! Interactive REPL channel for debugging and testing. +//! Interactive REPL channel with line editing and markdown rendering. //! -//! Provides a command-line interface for interacting with the agent. +//! Provides the primary CLI interface for interacting with the agent. +//! Uses rustyline for line editing, history, and tab-completion. +//! Uses termimad for rendering markdown responses inline. //! //! ## Commands //! @@ -14,23 +16,113 @@ //! - `/new` - Start a new thread //! - `yes`/`no`/`always` - Respond to tool approval prompts -use std::io::{self, BufRead, Write}; +use std::borrow::Cow; +use std::io::{self, Write}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use async_trait::async_trait; +use rustyline::completion::Completer; +use rustyline::config::Config; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::{CompletionType, Editor, Helper}; +use termimad::MadSkin; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::error::ChannelError; -/// REPL channel for interactive agent debugging. +/// Slash commands available in the REPL. +const SLASH_COMMANDS: &[&str] = &[ + "/help", + "/quit", + "/exit", + "/debug", + "/undo", + "/redo", + "/clear", + "/compact", + "/new", + "/interrupt", +]; + +/// Rustyline helper for slash-command tab completion. +struct ReplHelper; + +impl Completer for ReplHelper { + type Candidate = String; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &rustyline::Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + if !line.starts_with('/') { + return Ok((0, vec![])); + } + + let prefix = &line[..pos]; + let matches: Vec = SLASH_COMMANDS + .iter() + .filter(|cmd| cmd.starts_with(prefix)) + .map(|cmd| cmd.to_string()) + .collect(); + + Ok((0, matches)) + } +} + +impl Hinter for ReplHelper { + type Hint = String; + + fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option { + if !line.starts_with('/') || pos < line.len() { + return None; + } + + SLASH_COMMANDS + .iter() + .find(|cmd| cmd.starts_with(line) && **cmd != line) + .map(|cmd| cmd[line.len()..].to_string()) + } +} + +impl Highlighter for ReplHelper { + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + Cow::Owned(format!("\x1b[90m{hint}\x1b[0m")) + } +} + +impl Validator for ReplHelper {} +impl Helper for ReplHelper {} + +/// Build a termimad skin with our color scheme. +fn make_skin() -> MadSkin { + let mut skin = MadSkin::default(); + skin.set_headers_fg(termimad::crossterm::style::Color::Yellow); + skin.bold.set_fg(termimad::crossterm::style::Color::White); + skin.italic + .set_fg(termimad::crossterm::style::Color::Magenta); + skin.inline_code + .set_fg(termimad::crossterm::style::Color::Green); + skin.code_block + .set_fg(termimad::crossterm::style::Color::Green); + skin +} + +/// REPL channel with line editing and markdown rendering. pub struct ReplChannel { /// Optional single message to send (for -m flag). single_message: Option, /// Debug mode flag (shared with input thread). debug_mode: Arc, + /// Whether we're currently streaming (chunks have been printed without a trailing newline). + is_streaming: Arc, } impl ReplChannel { @@ -39,6 +131,7 @@ impl ReplChannel { Self { single_message: None, debug_mode: Arc::new(AtomicBool::new(false)), + is_streaming: Arc::new(AtomicBool::new(false)), } } @@ -47,6 +140,7 @@ impl ReplChannel { Self { single_message: Some(message), debug_mode: Arc::new(AtomicBool::new(false)), + is_streaming: Arc::new(AtomicBool::new(false)), } } @@ -64,7 +158,7 @@ impl Default for ReplChannel { fn print_help() { println!( r#" -IronClaw REPL - Interactive debugging mode +IronClaw REPL Commands: /help Show this help message @@ -90,6 +184,14 @@ Tips: ); } +/// Get the history file path (~/.ironclaw/history). +fn history_path() -> std::path::PathBuf { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".ironclaw") + .join("history") +} + #[async_trait] impl Channel for ReplChannel { fn name(&self) -> &str { @@ -102,38 +204,50 @@ impl Channel for ReplChannel { let debug_mode = Arc::clone(&self.debug_mode); std::thread::spawn(move || { - // If single message mode, send it and exit + // Single message mode: send it and return if let Some(msg) = single_message { let incoming = IncomingMessage::new("repl", "user", &msg); - if tx.blocking_send(incoming).is_err() { - return; - } - // Wait a bit for response, then the channel will close + let _ = tx.blocking_send(incoming); return; } - // Interactive REPL mode - let stdin = io::stdin(); - let mut stdout = io::stdout(); + // Set up rustyline + let config = Config::builder() + .history_ignore_dups(true) + .expect("valid config") + .auto_add_history(true) + .completion_type(CompletionType::List) + .build(); + + let mut rl = match Editor::with_config(config) { + Ok(editor) => editor, + Err(e) => { + eprintln!("Failed to initialize line editor: {e}"); + return; + } + }; + + rl.set_helper(Some(ReplHelper)); + + // Load history + let hist_path = history_path(); + if let Some(parent) = hist_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = rl.load_history(&hist_path); println!("IronClaw REPL - Type /help for commands, /quit to exit"); println!(); loop { - // Print prompt let prompt = if debug_mode.load(Ordering::Relaxed) { - "[debug] > " + "\x1b[33m[debug]\x1b[0m \x1b[36m>\x1b[0m " } else { - "> " + "\x1b[36m>\x1b[0m " }; - print!("{}", prompt); - let _ = stdout.flush(); - // Read line - let mut line = String::new(); - match stdin.lock().read_line(&mut line) { - Ok(0) => break, // EOF - Ok(_) => { + match rl.readline(prompt) { + Ok(line) => { let line = line.trim(); if line.is_empty() { continue; @@ -164,9 +278,26 @@ impl Channel for ReplChannel { break; } } - Err(_) => break, + Err(ReadlineError::Interrupted) => { + // Ctrl+C: send /interrupt + let msg = IncomingMessage::new("repl", "user", "/interrupt"); + if tx.blocking_send(msg).is_err() { + break; + } + } + Err(ReadlineError::Eof) => { + // Ctrl+D: quit + break; + } + Err(e) => { + eprintln!("Input error: {e}"); + break; + } } } + + // Save history on exit + let _ = rl.save_history(&history_path()); }); Ok(Box::pin(ReceiverStream::new(rx))) @@ -177,8 +308,23 @@ impl Channel for ReplChannel { _msg: &IncomingMessage, response: OutgoingResponse, ) -> Result<(), ChannelError> { + // If we were streaming, the content was already printed via StreamChunk. + // Just finish the line and reset. + if self.is_streaming.swap(false, Ordering::Relaxed) { + println!(); + println!(); + return Ok(()); + } + + // Render markdown + let skin = make_skin(); + let width = crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80); + let text = termimad::FmtText::from(&skin, &response.content, Some(width)); + println!(); - println!("{}", response.content); + print!("{text}"); println!(); Ok(()) } @@ -193,37 +339,27 @@ impl Channel for ReplChannel { match status { StatusUpdate::Thinking(msg) => { if debug { - eprintln!("\x1b[90m[thinking] {}\x1b[0m", msg); - } else { - eprint!("."); - let _ = io::stderr().flush(); + eprintln!("\x1b[90m[thinking] {msg}\x1b[0m"); } } StatusUpdate::ToolStarted { name } => { - if debug { - eprintln!("\x1b[33m[tool:start] {}\x1b[0m", name); - } else { - eprintln!("\x1b[33m⚡ {}\x1b[0m", name); - } + eprintln!(" \x1b[33m>> {name}\x1b[0m"); } StatusUpdate::ToolCompleted { name, success } => { - if debug { - if success { - eprintln!("\x1b[32m[tool:done] {} ✓\x1b[0m", name); - } else { - eprintln!("\x1b[31m[tool:fail] {} ✗\x1b[0m", name); - } - } else if !success { - eprintln!("\x1b[31m✗ {} failed\x1b[0m", name); + if success { + eprintln!(" \x1b[32m<< {name}\x1b[0m"); + } else { + eprintln!(" \x1b[31m<< {name} failed\x1b[0m"); } } StatusUpdate::StreamChunk(chunk) => { - print!("{}", chunk); + self.is_streaming.store(true, Ordering::Relaxed); + print!("{chunk}"); let _ = io::stdout().flush(); } StatusUpdate::Status(msg) => { if debug || msg.contains("approval") || msg.contains("Approval") { - eprintln!("\x1b[90m[status] {}\x1b[0m", msg); + eprintln!("\x1b[90m[status] {msg}\x1b[0m"); } } } @@ -235,9 +371,15 @@ impl Channel for ReplChannel { _user_id: &str, response: OutgoingResponse, ) -> Result<(), ChannelError> { - println!(); - println!("\x1b[36m[notification]\x1b[0m {}", response.content); - println!(); + let skin = make_skin(); + let width = crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80); + + eprintln!("\x1b[36m[notification]\x1b[0m"); + let text = termimad::FmtText::from(&skin, &response.content, Some(width)); + eprint!("{text}"); + eprintln!(); Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index fa3e9db1..e4344d21 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -41,10 +41,6 @@ pub struct Cli { #[arg(long, global = true)] pub no_db: bool, - /// Simple REPL mode without TUI (for testing) - #[arg(long, global = true)] - pub repl: bool, - /// Single message mode - send one message and exit #[arg(short, long, global = true)] pub message: Option, diff --git a/src/main.rs b/src/main.rs index 4e177154..14ea5eed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx use ironclaw::{ agent::{Agent, AgentDeps}, channels::{ - AppEvent, ChannelManager, HttpChannel, ReplChannel, TuiChannel, + ChannelManager, HttpChannel, ReplChannel, wasm::{ RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, WasmChannelServer, @@ -38,7 +38,7 @@ use ironclaw::{ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - // Handle non-agent commands first (they don't need TUI/full setup) + // Handle non-agent commands first (they don't need full setup) match &cli.command { Some(Command::Tool(tool_cmd)) => { // Simple logging for CLI commands @@ -177,8 +177,7 @@ async fn main() -> anyhow::Result<()> { Err(e) => return Err(e.into()), }; - // Initialize session manager and authenticate BEFORE TUI setup - // This allows the auth menu to display cleanly without TUI interference + // Initialize session manager and authenticate before channel setup let session_config = SessionConfig { auth_base_url: config.llm.nearai.auth_base_url.clone(), session_path: config.llm.nearai.session_path.clone(), @@ -187,57 +186,24 @@ async fn main() -> anyhow::Result<()> { let session = create_session_manager(session_config).await; // Ensure we're authenticated before proceeding (may trigger login flow) - // This happens before TUI so the menu displays correctly session.ensure_authenticated().await?; - // Initialize tracing and channels based on mode + // Initialize tracing let env_filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=debug")); - // Determine which mode to use: REPL, single message, or TUI - let use_repl = cli.repl || cli.message.is_some(); + tracing_subscriber::registry() + .with(env_filter) + .with(tracing_subscriber::fmt::layer().with_target(false)) + .init(); - // Create appropriate channel based on mode - let (tui_channel, tui_event_sender, repl_channel) = if use_repl { - // REPL mode - use simple stdin/stdout - tracing_subscriber::registry() - .with(env_filter) - .with(tracing_subscriber::fmt::layer().with_target(false)) - .init(); - - let repl = if let Some(ref msg) = cli.message { - ReplChannel::with_message(msg.clone()) - } else { - ReplChannel::new() - }; - - (None, None, Some(repl)) + // Create CLI channel + let repl_channel = if let Some(ref msg) = cli.message { + Some(ReplChannel::with_message(msg.clone())) } else if config.channels.cli.enabled { - // TUI mode - let channel = TuiChannel::new(); - let log_writer = channel.log_writer(); - let event_sender = channel.event_sender(); - - tracing_subscriber::registry() - .with(env_filter) - .with( - tracing_subscriber::fmt::layer() - .with_writer(log_writer) - .without_time() - .with_target(false) - .with_level(true), - ) - .init(); - - (Some(channel), Some(event_sender), None) + Some(ReplChannel::new()) } else { - // No CLI - just logging - tracing_subscriber::registry() - .with(env_filter) - .with(tracing_subscriber::fmt::layer().with_target(false)) - .init(); - - (None, None, None) + None }; tracing::info!("Starting IronClaw..."); @@ -259,34 +225,6 @@ async fn main() -> anyhow::Result<()> { let llm = create_llm_provider(&config.llm, session.clone())?; tracing::info!("LLM provider initialized: {}", llm.model_name()); - // Fetch available models and send to TUI (async, non-blocking) - if let Some(ref event_tx) = tui_event_sender { - let llm_for_models = llm.clone(); - let event_tx = event_tx.clone(); - tokio::spawn(async move { - match llm_for_models.list_models().await { - Ok(models) if !models.is_empty() => { - let _ = event_tx.send(AppEvent::AvailableModels(models)).await; - } - Ok(_) => { - let _ = event_tx - .send(AppEvent::ErrorMessage( - "No models available from API".into(), - )) - .await; - } - Err(e) => { - let _ = event_tx - .send(AppEvent::ErrorMessage(format!( - "Failed to fetch models: {}", - e - ))) - .await; - } - } - }); - } - // Initialize safety layer let safety = Arc::new(SafetyLayer::new(&config.safety)); tracing::info!("Safety layer initialized"); @@ -529,7 +467,6 @@ async fn main() -> anyhow::Result<()> { // Initialize channel manager let mut channels = ChannelManager::new(); - // Add REPL channel if in REPL mode if let Some(repl) = repl_channel { channels.add(Box::new(repl)); if cli.message.is_some() { @@ -538,14 +475,9 @@ async fn main() -> anyhow::Result<()> { tracing::info!("REPL mode enabled"); } } - // Add TUI channel if CLI is enabled (already created for logging hookup) - else if let Some(tui) = tui_channel { - channels.add(Box::new(tui)); - tracing::info!("TUI channel enabled"); - } // Add HTTP channel if configured and not CLI-only mode - if !cli.cli_only && !use_repl { + if !cli.cli_only { if let Some(ref http_config) = config.channels.http { channels.add(Box::new(HttpChannel::new(http_config.clone()))); tracing::info!( From 8439293df3328698483be7aa2f798a97448ee50b Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 6 Feb 2026 10:01:26 -0800 Subject: [PATCH 7/9] Fix WASM channel on-status instantiation failure and HTTP port conflict Consolidate channel sources into channels-src/ by moving whatsapp from channels/. Add on_status stubs to Slack and WhatsApp so their WASM binaries export the function added in the latest WIT. Fix Slack's emit_message call to pass by reference (API changed). Guard WASM webhook server startup to skip when the HTTP channel already occupies port 8080. Co-Authored-By: Claude Opus 4.6 --- channels-src/slack/Cargo.lock | 497 ++++++++++++++++++ channels-src/slack/src/lib.rs | 6 +- .../whatsapp/Cargo.lock | 0 .../whatsapp/Cargo.toml | 0 .../whatsapp/src/lib.rs | 4 +- .../whatsapp/whatsapp.capabilities.json | 0 src/main.rs | 10 +- 7 files changed, 512 insertions(+), 5 deletions(-) create mode 100644 channels-src/slack/Cargo.lock rename {channels => channels-src}/whatsapp/Cargo.lock (100%) rename {channels => channels-src}/whatsapp/Cargo.toml (100%) rename {channels => channels-src}/whatsapp/src/lib.rs (99%) rename {channels => channels-src}/whatsapp/whatsapp.capabilities.json (100%) diff --git a/channels-src/slack/Cargo.lock b/channels-src/slack/Cargo.lock new file mode 100644 index 00000000..4e646b06 --- /dev/null +++ b/channels-src/slack/Cargo.lock @@ -0,0 +1,497 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slack-channel" +version = "0.1.0" +dependencies = [ + "hex", + "hmac", + "serde", + "serde_json", + "sha2", + "wit-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1" +dependencies = [ + "leb128", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7" +dependencies = [ + "anyhow", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25" +dependencies = [ + "ahash", + "bitflags", + "hashbrown 0.14.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs index adb2c5aa..c54af12f 100644 --- a/channels-src/slack/src/lib.rs +++ b/channels-src/slack/src/lib.rs @@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize}; // Re-export generated types use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, - OutgoingHttpResponse, PollConfig, + OutgoingHttpResponse, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage}; @@ -269,6 +269,8 @@ impl Guest for SlackChannel { } } + fn on_status(_update: StatusUpdate) {} + fn on_shutdown() { channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down"); } @@ -339,7 +341,7 @@ fn emit_message( // Strip @ mentions of the bot from the text for cleaner messages let cleaned_text = strip_bot_mention(&text); - channel_host::emit_message(EmittedMessage { + channel_host::emit_message(&EmittedMessage { user_id, user_name: None, // Could fetch from Slack API if needed content: cleaned_text, diff --git a/channels/whatsapp/Cargo.lock b/channels-src/whatsapp/Cargo.lock similarity index 100% rename from channels/whatsapp/Cargo.lock rename to channels-src/whatsapp/Cargo.lock diff --git a/channels/whatsapp/Cargo.toml b/channels-src/whatsapp/Cargo.toml similarity index 100% rename from channels/whatsapp/Cargo.toml rename to channels-src/whatsapp/Cargo.toml diff --git a/channels/whatsapp/src/lib.rs b/channels-src/whatsapp/src/lib.rs similarity index 99% rename from channels/whatsapp/src/lib.rs rename to channels-src/whatsapp/src/lib.rs index 346c0f23..e28340b8 100644 --- a/channels/whatsapp/src/lib.rs +++ b/channels-src/whatsapp/src/lib.rs @@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize}; // Re-export generated types use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, - OutgoingHttpResponse, + OutgoingHttpResponse, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage}; @@ -417,6 +417,8 @@ impl Guest for WhatsAppChannel { } } + fn on_status(_update: StatusUpdate) {} + fn on_shutdown() { channel_host::log( channel_host::LogLevel::Info, diff --git a/channels/whatsapp/whatsapp.capabilities.json b/channels-src/whatsapp/whatsapp.capabilities.json similarity index 100% rename from channels/whatsapp/whatsapp.capabilities.json rename to channels-src/whatsapp/whatsapp.capabilities.json diff --git a/src/main.rs b/src/main.rs index 14ea5eed..cb0b25b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -618,8 +618,14 @@ async fn main() -> anyhow::Result<()> { channels.add(Box::new(SharedWasmChannel::new(channel_arc))); } - // Start WASM channel webhook server if we have channels with webhooks - if has_webhook_channels && config.tunnel.public_url.is_some() { + // Start WASM channel webhook server if we have channels with webhooks. + // Skip when the HTTP channel already occupies port 8080. + let http_uses_port = + config.channels.http.as_ref().map(|h| h.port) == Some(8080); + if has_webhook_channels + && config.tunnel.public_url.is_some() + && !http_uses_port + { let mut server = WasmChannelServer::new(wasm_router); if let Some(ref ext_mgr) = extension_manager { server = server.with_extension_manager(Arc::clone(ext_mgr)); From 9d156411fc3d3d038d972760dc7e3780a510397b Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 6 Feb 2026 11:12:17 -0800 Subject: [PATCH 8/9] Unify webhook servers into single WebhookServer Replace the dual-server architecture (HttpChannel + WasmChannelServer both competing for port 8080) with a single WebhookServer that composes route fragments from all sources. Channels define routes but never spawn servers. - Add WebhookServer struct that collects Router fragments and binds one listener - Extract routes() from HttpChannel, remove server-spawning from start/shutdown - Delete WasmChannelServer (keep WasmChannelRouter and route builder) - Rewire main.rs to compose all webhook routes into one server Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 ++ src/channels/http.rs | 82 ++++++++------------------ src/channels/mod.rs | 2 + src/channels/wasm/mod.rs | 4 +- src/channels/wasm/router.rs | 51 ---------------- src/channels/webhook_server.rs | 92 +++++++++++++++++++++++++++++ src/main.rs | 104 ++++++++++++++++----------------- 7 files changed, 174 insertions(+), 165 deletions(-) create mode 100644 src/channels/webhook_server.rs diff --git a/CLAUDE.md b/CLAUDE.md index e55bcc24..c26c7a14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,10 @@ src/ ## Key Patterns +### Architecture + +When designing new features or systems, always prefer generic/extensible architectures over hardcoding specific integrations. Ask clarifying questions about the desired abstraction level before implementing. + ### Error Handling - Use `thiserror` for error types in `error.rs` - Never use `.unwrap()` in production code (tests are fine) diff --git a/src/channels/http.rs b/src/channels/http.rs index 986ebf7c..77576a46 100644 --- a/src/channels/http.rs +++ b/src/channels/http.rs @@ -1,6 +1,5 @@ //! HTTP webhook channel for receiving messages via HTTP POST. -use std::net::SocketAddr; use std::sync::Arc; use async_trait::async_trait; @@ -32,8 +31,6 @@ struct HttpChannelState { tx: RwLock>>, /// Pending responses keyed by message ID. pending_responses: RwLock>>, - /// Server shutdown signal. - shutdown_tx: RwLock>>, /// Expected webhook secret for authentication (if configured). webhook_secret: Option, /// Fixed user ID for this HTTP channel. @@ -74,7 +71,6 @@ impl HttpChannel { state: Arc::new(HttpChannelState { tx: RwLock::new(None), pending_responses: RwLock::new(std::collections::HashMap::new()), - shutdown_tx: RwLock::new(None), webhook_secret, user_id, rate_limit: tokio::sync::Mutex::new(RateLimitState { @@ -84,6 +80,24 @@ impl HttpChannel { }), } } + + /// Return the channel's axum routes with state applied. + /// + /// The returned `Router` shares the same `Arc` that + /// `start()` later populates. Before `start()` is called the webhook + /// handler returns 503 ("Channel not started"). + pub fn routes(&self) -> Router { + Router::new() + .route("/health", get(health_handler)) + .route("/webhook", post(webhook_handler)) + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) + .with_state(self.state.clone()) + } + + /// Return the configured host and port for this channel. + pub fn addr(&self) -> (&str, u16) { + (&self.config.host, self.config.port) + } } #[derive(Debug, Deserialize)] @@ -303,53 +317,11 @@ impl Channel for HttpChannel { let (tx, rx) = mpsc::channel(256); *self.state.tx.write().await = Some(tx); - let state = self.state.clone(); - let host = self.config.host.clone(); - let port = self.config.port; - - // Parse address before spawning so we can return errors - let addr: SocketAddr = - format!("{}:{}", host, port) - .parse() - .map_err(|e| ChannelError::StartupFailed { - name: "http".to_string(), - reason: format!("Invalid address '{}:{}': {}", host, port, e), - })?; - - // Bind listener before spawning so we can return errors - let listener = - tokio::net::TcpListener::bind(addr) - .await - .map_err(|e| ChannelError::StartupFailed { - name: "http".to_string(), - reason: format!("Failed to bind to {}: {}", addr, e), - })?; - - tracing::info!("HTTP channel listening on {}", addr); - - // Create router - let app = Router::new() - .route("/health", get(health_handler)) - .route("/webhook", post(webhook_handler)) - .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) - .with_state(state.clone()); - - // Create shutdown channel - let (shutdown_tx, shutdown_rx) = oneshot::channel(); - *self.state.shutdown_tx.write().await = Some(shutdown_tx); - - // Spawn server (listener is already bound, serve errors are logged) - tokio::spawn(async move { - if let Err(e) = axum::serve(listener, app) - .with_graceful_shutdown(async { - let _ = shutdown_rx.await; - tracing::info!("HTTP channel shutting down"); - }) - .await - { - tracing::error!("HTTP server error: {}", e); - } - }); + tracing::info!( + "HTTP channel ready ({}:{})", + self.config.host, + self.config.port + ); Ok(Box::pin(ReceiverStream::new(rx))) } @@ -363,13 +335,10 @@ impl Channel for HttpChannel { if let Some(tx) = self.state.pending_responses.write().await.remove(&msg.id) { let _ = tx.send(response.content); } - // For async webhooks, we'd need to make an HTTP callback here - // but that requires the caller to provide a callback URL Ok(()) } async fn health_check(&self) -> Result<(), ChannelError> { - // Check if we have an active sender if self.state.tx.read().await.is_some() { Ok(()) } else { @@ -380,11 +349,6 @@ impl Channel for HttpChannel { } async fn shutdown(&self) -> Result<(), ChannelError> { - // Send shutdown signal - if let Some(tx) = self.state.shutdown_tx.write().await.take() { - let _ = tx.send(()); - } - // Clear the message sender *self.state.tx.write().await = None; Ok(()) } diff --git a/src/channels/mod.rs b/src/channels/mod.rs index fc85436e..3796cc20 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -32,8 +32,10 @@ mod http; mod manager; mod repl; pub mod wasm; +mod webhook_server; pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; pub use http::HttpChannel; pub use manager::ChannelManager; pub use repl::ReplChannel; +pub use webhook_server::{WebhookServer, WebhookServerConfig}; diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 7535d033..e6f4f0d8 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -95,9 +95,7 @@ pub use loader::{ DiscoveredChannel, LoadResults, LoadedChannel, WasmChannelLoader, default_channels_dir, discover_channels, }; -pub use router::{ - RegisteredEndpoint, WasmChannelRouter, WasmChannelServer, create_wasm_channel_router, -}; +pub use router::{RegisteredEndpoint, WasmChannelRouter, create_wasm_channel_router}; pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig}; pub use schema::{ ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema, diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 44f81f99..0bd3182f 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -4,7 +4,6 @@ //! registered paths. Handles secret validation at the host level. use std::collections::HashMap; -use std::net::SocketAddr; use std::sync::Arc; use axum::{ @@ -469,56 +468,6 @@ pub fn create_wasm_channel_router( .with_state(state) } -/// HTTP server for WASM channel webhooks. -pub struct WasmChannelServer { - router: Arc, - extension_manager: Option>, -} - -impl WasmChannelServer { - /// Create a new server. - pub fn new(router: Arc) -> Self { - Self { - router, - extension_manager: None, - } - } - - /// Set the extension manager for OAuth callback handling. - pub fn with_extension_manager( - mut self, - manager: Arc, - ) -> Self { - self.extension_manager = Some(manager); - self - } - - /// Start the HTTP server. - /// - /// Returns a handle that can be used to shut down the server. - pub async fn start( - &self, - addr: SocketAddr, - ) -> Result, std::io::Error> { - let app = create_wasm_channel_router(self.router.clone(), self.extension_manager.clone()); - - let listener = tokio::net::TcpListener::bind(addr).await?; - - tracing::info!( - addr = %addr, - "WASM channel HTTP server started" - ); - - let handle = tokio::spawn(async move { - if let Err(e) = axum::serve(listener, app).await { - tracing::error!("WASM channel HTTP server error: {}", e); - } - }); - - Ok(handle) - } -} - #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/src/channels/webhook_server.rs b/src/channels/webhook_server.rs new file mode 100644 index 00000000..e38341f6 --- /dev/null +++ b/src/channels/webhook_server.rs @@ -0,0 +1,92 @@ +//! Unified HTTP server for all webhook routes. +//! +//! Composes route fragments from HttpChannel, WASM channel router, etc. +//! into a single axum server. Channels define routes but never spawn servers. + +use std::net::SocketAddr; + +use axum::Router; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; + +use crate::error::ChannelError; + +/// Configuration for the unified webhook server. +pub struct WebhookServerConfig { + /// Address to bind the server to. + pub addr: SocketAddr, +} + +/// A single HTTP server that hosts all webhook routes. +/// +/// Channels contribute route fragments via `add_routes()`, then a single +/// `start()` call binds the listener and spawns the server task. +pub struct WebhookServer { + config: WebhookServerConfig, + routes: Vec, + shutdown_tx: Option>, + handle: Option>, +} + +impl WebhookServer { + /// Create a new webhook server with the given bind address. + pub fn new(config: WebhookServerConfig) -> Self { + Self { + config, + routes: Vec::new(), + shutdown_tx: None, + handle: None, + } + } + + /// Accumulate a route fragment. Each fragment should already have its + /// state applied via `.with_state()`. + pub fn add_routes(&mut self, router: Router) { + self.routes.push(router); + } + + /// Bind the listener, merge all route fragments, and spawn the server. + pub async fn start(&mut self) -> Result<(), ChannelError> { + let mut app = Router::new(); + for fragment in self.routes.drain(..) { + app = app.merge(fragment); + } + + let listener = tokio::net::TcpListener::bind(self.config.addr) + .await + .map_err(|e| ChannelError::StartupFailed { + name: "webhook_server".to_string(), + reason: format!("Failed to bind to {}: {}", self.config.addr, e), + })?; + + tracing::info!("Webhook server listening on {}", self.config.addr); + + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + self.shutdown_tx = Some(shutdown_tx); + + let handle = tokio::spawn(async move { + if let Err(e) = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + tracing::info!("Webhook server shutting down"); + }) + .await + { + tracing::error!("Webhook server error: {}", e); + } + }); + + self.handle = Some(handle); + Ok(()) + } + + /// Signal graceful shutdown and wait for the server task to finish. + pub async fn shutdown(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + if let Some(handle) = self.handle.take() { + let _ = handle.await; + } + } +} diff --git a/src/main.rs b/src/main.rs index cb0b25b0..7314aee6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,10 +8,10 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx use ironclaw::{ agent::{Agent, AgentDeps}, channels::{ - ChannelManager, HttpChannel, ReplChannel, + ChannelManager, HttpChannel, ReplChannel, WebhookServer, WebhookServerConfig, wasm::{ RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter, - WasmChannelRuntime, WasmChannelRuntimeConfig, WasmChannelServer, + WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router, }, }, cli::{ @@ -476,19 +476,10 @@ async fn main() -> anyhow::Result<()> { } } - // Add HTTP channel if configured and not CLI-only mode - if !cli.cli_only { - if let Some(ref http_config) = config.channels.http { - channels.add(Box::new(HttpChannel::new(http_config.clone()))); - tracing::info!( - "HTTP channel enabled on {}:{}", - http_config.host, - http_config.port - ); - } - } + // Collect webhook route fragments; a single WebhookServer hosts them all. + let mut webhook_routes: Vec = Vec::new(); - // Load WASM channels if enabled + // Load WASM channels and register their webhook routes. if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() { match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) { Ok(runtime) => { @@ -500,7 +491,6 @@ async fn main() -> anyhow::Result<()> { .await { Ok(results) => { - // Create router for WASM channel webhooks let wasm_router = Arc::new(WasmChannelRouter::new()); let mut has_webhook_channels = false; @@ -508,10 +498,8 @@ async fn main() -> anyhow::Result<()> { let channel_name = loaded.name().to_string(); tracing::info!("Loaded WASM channel: {}", channel_name); - // Get webhook secret name from capabilities (generic) let secret_name = loaded.webhook_secret_name(); - // Get webhook secret for this channel from secrets store let webhook_secret = if let Some(ref secrets) = secrets_store { secrets .get_decrypted("default", &secret_name) @@ -522,12 +510,9 @@ async fn main() -> anyhow::Result<()> { None }; - // Get the secret header name from capabilities let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); - // Register channel with router for webhook handling - // Use known webhook path based on channel name let webhook_path = format!("/webhook/{}", channel_name); let endpoints = vec![RegisteredEndpoint { channel_name: channel_name.clone(), @@ -538,8 +523,6 @@ async fn main() -> anyhow::Result<()> { let channel_arc = Arc::new(loaded.channel); - // Inject runtime config into the channel (tunnel_url, webhook_secret) - // This must be done before start() is called { let mut config_updates = std::collections::HashMap::new(); @@ -585,7 +568,6 @@ async fn main() -> anyhow::Result<()> { .await; has_webhook_channels = true; - // Inject credentials for this channel (generic pattern-based injection) if let Some(ref secrets) = secrets_store { match inject_channel_credentials( &channel_arc, @@ -613,38 +595,14 @@ async fn main() -> anyhow::Result<()> { } } - // Wrap in SharedWasmChannel for ChannelManager - // Both the router and ChannelManager share the same underlying channel channels.add(Box::new(SharedWasmChannel::new(channel_arc))); } - // Start WASM channel webhook server if we have channels with webhooks. - // Skip when the HTTP channel already occupies port 8080. - let http_uses_port = - config.channels.http.as_ref().map(|h| h.port) == Some(8080); - if has_webhook_channels - && config.tunnel.public_url.is_some() - && !http_uses_port - { - let mut server = WasmChannelServer::new(wasm_router); - if let Some(ref ext_mgr) = extension_manager { - server = server.with_extension_manager(Arc::clone(ext_mgr)); - } - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 8080)); - match server.start(addr).await { - Ok(_handle) => { - tracing::info!( - "WASM channel webhook server started on {}", - addr - ); - } - Err(e) => { - tracing::error!( - "Failed to start WASM channel webhook server: {}", - e - ); - } - } + if has_webhook_channels && config.tunnel.public_url.is_some() { + webhook_routes.push(create_wasm_channel_router( + wasm_router, + extension_manager.as_ref().map(Arc::clone), + )); } for (path, err) in &results.errors { @@ -666,6 +624,43 @@ async fn main() -> anyhow::Result<()> { } } + // Add HTTP channel if configured and not CLI-only mode. + // Extract its routes for the unified server; the channel itself just + // provides the mpsc stream. + let mut webhook_server_addr: Option = None; + if !cli.cli_only { + if let Some(ref http_config) = config.channels.http { + let http_channel = HttpChannel::new(http_config.clone()); + webhook_routes.push(http_channel.routes()); + let (host, port) = http_channel.addr(); + webhook_server_addr = Some( + format!("{}:{}", host, port) + .parse() + .expect("HttpConfig host:port must be a valid SocketAddr"), + ); + channels.add(Box::new(http_channel)); + tracing::info!( + "HTTP channel enabled on {}:{}", + http_config.host, + http_config.port + ); + } + } + + // Start the unified webhook server if any routes were registered. + let mut webhook_server = if !webhook_routes.is_empty() { + let addr = + webhook_server_addr.unwrap_or_else(|| std::net::SocketAddr::from(([0, 0, 0, 0], 8080))); + let mut server = WebhookServer::new(WebhookServerConfig { addr }); + for routes in webhook_routes { + server.add_routes(routes); + } + server.start().await?; + Some(server) + } else { + None + }; + // Create workspace for agent (shared with memory tools) let workspace = store.as_ref().map(|s| { let mut ws = Workspace::new("default", s.pool()); @@ -715,6 +710,11 @@ async fn main() -> anyhow::Result<()> { // Run the agent (blocks until shutdown) agent.run().await?; + // Shut down the webhook server if one was started + if let Some(ref mut server) = webhook_server { + server.shutdown().await; + } + tracing::info!("Agent shutdown complete"); Ok(()) } From f34a80191ec0938696e5d543d62207ff8891ef32 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 6 Feb 2026 11:53:09 -0800 Subject: [PATCH 9/9] Rename examples/ to tools-src/ Update doc references in CLAUDE.md and slack README. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 ++-- {examples => tools-src}/wasm-tools/slack/.gitignore | 0 {examples => tools-src}/wasm-tools/slack/Cargo.toml | 0 {examples => tools-src}/wasm-tools/slack/README.md | 2 +- .../wasm-tools/slack/slack-tool.capabilities.json | 0 {examples => tools-src}/wasm-tools/slack/src/api.rs | 0 {examples => tools-src}/wasm-tools/slack/src/lib.rs | 0 {examples => tools-src}/wasm-tools/slack/src/types.rs | 0 8 files changed, 3 insertions(+), 3 deletions(-) rename {examples => tools-src}/wasm-tools/slack/.gitignore (100%) rename {examples => tools-src}/wasm-tools/slack/Cargo.toml (100%) rename {examples => tools-src}/wasm-tools/slack/README.md (99%) rename {examples => tools-src}/wasm-tools/slack/slack-tool.capabilities.json (100%) rename {examples => tools-src}/wasm-tools/slack/src/api.rs (100%) rename {examples => tools-src}/wasm-tools/slack/src/lib.rs (100%) rename {examples => tools-src}/wasm-tools/slack/src/types.rs (100%) diff --git a/CLAUDE.md b/CLAUDE.md index c26c7a14..5a127104 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -335,13 +335,13 @@ Key test patterns: WASM tools are the preferred way to add new capabilities. They run in a sandboxed environment with explicit capabilities. -1. Create a new crate in `examples/wasm-tools//` +1. Create a new crate in `tools-src/wasm-tools//` 2. Implement the WIT interface (`wit/tool.wit`) 3. Create `.capabilities.json` declaring required permissions 4. Build with `cargo build --target wasm32-wasip2 --release` 5. Install with `ironclaw tool install path/to/tool.wasm` -See `examples/wasm-tools/` for examples. +See `tools-src/wasm-tools/` for examples. ## Tool Architecture Principles diff --git a/examples/wasm-tools/slack/.gitignore b/tools-src/wasm-tools/slack/.gitignore similarity index 100% rename from examples/wasm-tools/slack/.gitignore rename to tools-src/wasm-tools/slack/.gitignore diff --git a/examples/wasm-tools/slack/Cargo.toml b/tools-src/wasm-tools/slack/Cargo.toml similarity index 100% rename from examples/wasm-tools/slack/Cargo.toml rename to tools-src/wasm-tools/slack/Cargo.toml diff --git a/examples/wasm-tools/slack/README.md b/tools-src/wasm-tools/slack/README.md similarity index 99% rename from examples/wasm-tools/slack/README.md rename to tools-src/wasm-tools/slack/README.md index aee92f18..c1c9efaf 100644 --- a/examples/wasm-tools/slack/README.md +++ b/tools-src/wasm-tools/slack/README.md @@ -34,7 +34,7 @@ A standalone WASM component that provides Slack integration for IronClaw. This s ## Building ```bash -cd examples/wasm-tools/slack +cd tools-src/wasm-tools/slack cargo component build --release ``` diff --git a/examples/wasm-tools/slack/slack-tool.capabilities.json b/tools-src/wasm-tools/slack/slack-tool.capabilities.json similarity index 100% rename from examples/wasm-tools/slack/slack-tool.capabilities.json rename to tools-src/wasm-tools/slack/slack-tool.capabilities.json diff --git a/examples/wasm-tools/slack/src/api.rs b/tools-src/wasm-tools/slack/src/api.rs similarity index 100% rename from examples/wasm-tools/slack/src/api.rs rename to tools-src/wasm-tools/slack/src/api.rs diff --git a/examples/wasm-tools/slack/src/lib.rs b/tools-src/wasm-tools/slack/src/lib.rs similarity index 100% rename from examples/wasm-tools/slack/src/lib.rs rename to tools-src/wasm-tools/slack/src/lib.rs diff --git a/examples/wasm-tools/slack/src/types.rs b/tools-src/wasm-tools/slack/src/types.rs similarity index 100% rename from examples/wasm-tools/slack/src/types.rs rename to tools-src/wasm-tools/slack/src/types.rs