@setoelkahfi / sigit / commits / 59e0ce9

Rewrite and clarify README and docs for conciseness

paydii committed Apr 28, 2026 at 07:04 UTC 59e0ce9e5ff2ac803ef5b2c5c8c4ffeb108a1a80
8 files changed +218 -533
README.md
+22 -24
index 89fb579..49b1952 100644 --- a/README.md +++ b/README.md @@ -10,34 +10,32 @@ siGit is a coding agent that runs on your machine. No API keys. No cloud round-trips. -It is meant to be useful anywhere, but it is especially good in smbCloud codebases. It already knows the rough shape of the platform: Rust workspaces with focused crates, Rails services, deploy flows, auth boundaries, and platform-managed services like GresIQ. In smbCloud repos, that usually means less back-and-forth and fewer generic answers. +It works in any codebase, but it's particularly at home in smbCloud repos. It knows the shape of the platform: Rust workspaces, Rails services, deploy flows, auth boundaries, GresIQ. That means less back-and-forth and fewer generic answers when you're working on smbCloud stuff. -siGit has two main modes: +Two modes: -- ACP mode, where Zed or another ACP-compatible editor starts it over stdio -- an interactive terminal chat when you run `sigit` yourself +- **ACP mode** — Zed or another ACP-compatible editor starts it over stdio +- **Terminal mode** — run `sigit` directly for an interactive chat -Current platform support: +| Platform | ACP mode | Terminal mode | +|----------|----------|---------------| +| macOS | ✓ | ✓ | +| Linux | ✓ | ✓ | +| Windows | ✓ | not yet | -- macOS: ACP mode and interactive terminal mode -- Linux: ACP mode and interactive terminal mode -- Windows: ACP mode only for now +## smbCloud context -## What siGit knows about smbCloud +When siGit is in an smbCloud repo, it uses platform context instead of giving generic cloud-app advice: -When siGit is working in an smbCloud repo, it should use platform context instead of treating everything like a generic cloud app. That includes things like: +- platform user flows vs. tenant app auth flows are different things +- `Project` is the umbrella workspace; `FrontendApp`, `AuthApp`, and GresIQ are separate deployable units +- Next.js SSR deploys aren't the same as the git-push path +- existing workspace patterns and crate boundaries over new abstractions -- the difference between platform user flows and tenant app auth flows -- the fact that `Project` is the umbrella workspace, while app-like resources such as `FrontendApp`, `AuthApp`, and GresIQ are separate deployable units -- the fact that Next.js SSR deploys are not the same as the generic git-push path -- the fact that smbCloud repos usually prefer existing workspace patterns and crate boundaries over new abstractions - -Outside smbCloud, it should still behave like a normal coding agent and not force platform-specific advice into places where it does not belong. +Outside smbCloud repos it stays general. No platform-specific advice where it doesn't belong. ## Install -Install siGit Code with Cargo, Homebrew, pip, or npm: - ```sh cargo install sigit ``` @@ -51,9 +49,9 @@ cargo install sigit ## First run -The first time siGit starts, it downloads a GGUF model, usually around 1 to 2 GB, from Hugging Face. After that, it loads from disk in a few seconds. +On first launch siGit downloads a GGUF model from Hugging Face, usually 1–2 GB. After that it loads from disk in a few seconds. -On macOS, siGit shares its model cache with the siGit desktop app through an App Group container. If the desktop app already downloaded the model, the CLI reuses it. +On macOS, the model cache is shared with the siGit desktop app through an App Group container. If the desktop app already has the model, the CLI reuses it. ## Zed setup @@ -70,7 +68,7 @@ Add to `~/.config/zed/settings.json`: } ``` -Use the full absolute path. `~` does not get expanded here. +Use the full absolute path. `~` doesn't expand here. ## VSCode via ACP Client extension @@ -90,9 +88,9 @@ Install [ACP client](https://marketplace.visualstudio.com/items?itemName=formula ## Terminal mode -If you run `sigit` directly in a terminal, it opens an interactive chat UI. It uses the same model and system prompt as the editor integration, so it is handy for quick questions when you do not want to open Zed first. +Run `sigit` in a terminal and you get an interactive chat UI. Same model and system prompt as the editor integration, just without opening Zed. -That terminal mode still depends on Unix terminal behavior, so it works on macOS and Linux. On Windows, siGit supports ACP and editor mode only for now. +Terminal mode needs Unix terminal behavior, so macOS and Linux only. Windows gets ACP mode for now. ## Platform support @@ -104,7 +102,7 @@ That terminal mode still depends on Unix terminal behavior, so it works on macOS ## License -Licensed under **Apache 2.0**. See [LICENSE](https://github.com/getsigit/sigit/blob/main/LICENSE). +[Apache 2.0](https://github.com/getsigit/sigit/blob/main/LICENSE) ## Copyright
npm/README.md.tmpl
+3 -3
index da5f30a..bc9f8f8 100644 --- a/npm/README.md.tmpl +++ b/npm/README.md.tmpl @@ -2,7 +2,7 @@ <p align="center"> AI coding agent powered by local LLM via <a href="https://ondeinference.com">Onde Inference</a>.<br> - Runs entirely on your machine — no API keys, no cloud round-trips. + Runs on your machine. No API keys, no cloud. </p> <p align="center"> @@ -17,9 +17,9 @@ ## `${node_pkg}` -This is the **npm** distribution of siGit Code — a platform-specific binary package for [`@smbcloud/sigit`](https://www.npmjs.com/package/@smbcloud/sigit). You don't need to install it directly. +This is the npm distribution of siGit Code, a platform-specific binary package for [`@smbcloud/sigit`](https://www.npmjs.com/package/@smbcloud/sigit). You don't need to install it directly. -Install the main package and npm pulls in the right binary for your OS and architecture automatically: +Install the main package and npm pulls in the right binary for your OS and architecture: ```sh npm install -g @smbcloud/sigit
pypi/README.md
+16 -25
index f9c55ec..cb45537 100644 --- a/pypi/README.md +++ b/pypi/README.md @@ -1,8 +1,8 @@ <h1 align="center">siGit Code</h1> <p align="center"> - <strong>AI coding agent powered by local LLM via <a href="https://ondeinference.com">Onde Inference</a>.</strong><br> - ACP-compatible agent that runs entirely on your machine — no API keys, no cloud. + AI coding agent powered by local LLM via <a href="https://ondeinference.com">Onde Inference</a>.<br> + Runs on your machine. No API keys, no cloud. </p> <p align="center"> @@ -19,26 +19,24 @@ ## Install -Use `pip` or `uv`: - ```sh pip install sigit-code uvx --from sigit-code sigit ``` -Installs the native `sigit` binary for your platform — no compiler, no Node.js, no runtime dependencies. +Installs a native `sigit` binary for your platform. No compiler, no runtime dependencies. ## Quick start -### Interactive TUI +### Terminal ```sh sigit ``` -A terminal UI opens where you can chat with a local LLM coding agent directly. +Opens a chat UI where you talk to a local LLM coding agent directly. -### Zed editor (ACP agent) +### Zed (ACP agent) siGit works as an [ACP-compatible](https://github.com/nicobailon/agent-client-protocol) agent in [Zed](https://zed.dev). Add this to your Zed settings: @@ -53,9 +51,9 @@ siGit works as an [ACP-compatible](https://github.com/nicobailon/agent-client-pr } ``` -Then select **sigit** as your agent profile in the Zed assistant panel. +Then pick **sigit** as your agent in the Zed assistant panel. -## Other installation methods +## Other install methods | Method | Command | |--------|---------| @@ -74,27 +72,19 @@ cargo build --release ## Platform support -Pre-built native binaries ship for every major platform: - -| Platform | Architecture | -|---------------|--------------| -| macOS | arm64, x64 | -| Linux (glibc) | arm64, x64 | -| Windows | arm64, x64 | +| Platform | Architecture | +|----------|--------------| +| macOS | arm64, x64 | +| Linux (glibc) | arm64, x64 | +| Windows | arm64, x64 | -## Source & issues +## Source and issues -This package ships a pre-built native binary. Source lives at -[github.com/getsigit/sigit](https://github.com/getsigit/sigit) — -file bugs and feature requests there. +This package ships a pre-built binary. Source code is at [github.com/getsigit/sigit](https://github.com/getsigit/sigit). File bugs there. ## License -Licensed under **Apache 2.0**. - -- [Apache License 2.0](https://github.com/getsigit/sigit/blob/main/LICENSE) - ---- +[Apache 2.0](https://github.com/getsigit/sigit/blob/main/LICENSE) ## Copyright
src/chat.rs
+49 -130
index 7bb639f..85e52ba 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1,44 +1,33 @@ -//! Full-screen terminal chat UI for siGit Code. +//! Full-screen terminal chat UI. //! -//! Takes over the alternate screen and multiplexes terminal events with -//! streaming LLM tokens via `tokio::select!`. -//! -//! The UI has two phases: -//! -//! 1. **Loading phase** — a centered spinner is shown while the model loads -//! in the background. The oneshot channel from the caller signals -//! completion or failure. -//! 2. **Chat phase** — normal interactive chat once `load_rx` resolves. +//! Two phases: a loading spinner while the model initializes, then +//! interactive chat. Uses `tokio::select!` to multiplex terminal events +//! with streaming LLM tokens. // ── Think-block stripping ───────────────────────────────────────────────────── -/// Strip `<think>…</think>` blocks from a model response. -/// -/// Qwen 3 models emit `<think>…</think>` before the real answer. This -/// function separates the thinking content from the visible reply so the -/// UI can render them differently (dimmed / collapsed). +/// Split out `<think>…</think>` blocks from a model response. /// +/// Qwen 3 emits reasoning inside `<think>` tags before the actual answer. /// Returns `(thinking_text, visible_reply)`. Either may be empty. pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) { let mut thinking = String::new(); let mut remainder = raw; while let Some(start) = remainder.find("<think>") { - // Text before <think> is visible. let before = &remainder[..start]; if let Some(end) = remainder[start..].find("</think>") { let block = &remainder[start + 7..start + end]; thinking.push_str(block.trim()); remainder = &remainder[start + end + 8..]; - // Prepend any text before <think> to the leftover. if !before.trim().is_empty() { - // Unusual — text before <think>. Keep it visible. + // rare: text before <think> — keep it visible let mut combined = before.to_string(); combined.push_str(remainder); return (thinking, combined.trim().to_string()); } } else { - // Unclosed <think> — treat rest as thinking (model ran out of tokens). + // unclosed tag — model probably ran out of tokens thinking.push_str(remainder[start + 7..].trim()); remainder = before; break; @@ -50,8 +39,7 @@ pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) { // ── Unix-only TUI ───────────────────────────────────────────────────────────── // -// Everything below this point is compiled only on Unix (macOS + Linux). -// Windows supports ACP mode only; the interactive TUI is not available there. +// macOS + Linux only. Windows uses ACP mode instead. #[cfg(unix)] mod tui { @@ -82,14 +70,14 @@ mod tui { User, Assistant, System, - /// Banner art — each character gets its own color. + /// rainbow-colored banner art Banner, } struct ChatMessage { role: Role, text: String, - /// Extracted `<think>…</think>` content, if any (Qwen 3 reasoning). + /// Qwen 3 reasoning extracted from `<think>` tags, if any. think_block: Option<String>, } @@ -131,13 +119,10 @@ mod tui { // ── Inference updates from background task ──────────────────────────────── - /// Messages sent from the spawned inference task back to the event loop. enum InferenceUpdate { - /// The model is calling a tool — show its name in the chat. + /// show tool name in chat while it runs ToolUse(String), - /// The model produced a final text response. Response(String), - /// Something went wrong during inference. Error(String), } @@ -155,39 +140,26 @@ mod tui { scroll_offset: u16, stream_rx: Option<mpsc::Receiver<StreamChunk>>, stream_buf: String, - /// Channel for receiving results from the background inference task. inference_rx: Option<mpsc::Receiver<InferenceUpdate>>, - /// Channel for receiving results from a model switch. model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>, - /// True while waiting for inference to finish. thinking: bool, - /// Counter driving the thinking spinner animation. thinking_tick: u8, quit: bool, - /// Flips every few ticks while streaming to make the cursor blink. + /// toggled periodically so the streaming cursor blinks blink_on: bool, blink_counter: u8, - /// True while a model switch is in progress. switching_model: bool, - /// Tool-calling flag for the model currently being loaded in the background. - /// Applied to `app.tool_calling` when `ModelLoadUpdate::Loaded` arrives. + /// stashed until ModelLoadUpdate::Loaded applies it to `app.tool_calling` pending_tool_calling: Option<bool>, - /// Set to true when the user cancels a model switch with Ctrl+C. - /// Suppresses the "loader task disconnected" error message that would - /// otherwise appear when we drop model_load_rx to abort the switch. + /// suppresses the spurious "disconnected" error when we drop model_load_rx on cancel model_load_cancelled: bool, // ── Loading-phase state ─────────────────────────────────────────────── - /// True while the model is still loading; switches to false on completion. is_loading: bool, - /// Monotonic counter incremented on every animation tick. Drives the - /// braille spinner shown during loading. load_tick: u32, - /// Set when model loading fails; keeps the loading view up with the error. + /// keeps the loading view visible so the user can read the error load_error: Option<String>, - /// When loading started — drives the elapsed-time counter. load_start: Instant, - /// Display name of the model being loaded (shown in the spinner line). load_model_name: String, // ── Model picker state ──────────────────────────────────────────────── @@ -195,16 +167,11 @@ mod tui { model_picker_index: usize, model_picker_items: Vec<ModelPickerItem>, current_model_name: String, - /// Whether the currently loaded model supports tool calling. tool_calling: bool, // ── Model-switch download progress ──────────────────────────────────── - /// The model_id of the model currently being downloaded/switched to. - /// `None` when no switch is in progress. switching_model_id: Option<String>, - /// Bytes on disk / expected bytes for the in-progress download. - /// Updated every 100 ms tick while `switching_model` is true and the - /// selected model was not yet cached. + /// (downloaded, expected) bytes — polled every tick during a model switch download_progress: Option<(u64, u64)>, } @@ -223,7 +190,6 @@ mod tui { 55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555 88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888"; - /// Spinner frames for the "thinking" animation. const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; impl App { @@ -267,7 +233,6 @@ mod tui { } } - /// True when either streaming tokens or waiting for inference. fn is_busy(&self) -> bool { self.is_streaming() || self.thinking || self.switching_model } @@ -315,13 +280,11 @@ mod tui { THINKING_FRAMES[idx] } - /// Advance the spinner tick counter. fn tick(&mut self) { self.load_tick = self.load_tick.wrapping_add(1); } - /// Poll the HF cache directory for the model being switched to and update - /// `download_progress`. Called on every 100 ms tick while switching. + /// check how much of the model has landed on disk so far fn poll_download_progress(&mut self) { let Some(ref model_id) = self.switching_model_id else { return; @@ -340,8 +303,7 @@ mod tui { self.download_progress = Some((downloaded, expected)); } - /// Transition from loading phase to normal chat. - /// Adds the banner art and welcome messages to the message log. + /// switch to chat phase and show the welcome banner fn finish_loading(&mut self) { self.is_loading = false; for line in BANNER_ART.lines() { @@ -359,8 +321,7 @@ mod tui { .push(ChatMessage::system("Type /help for commands.")); } - /// Record a loading error. The loading view stays visible so the user can - /// read the message before pressing Ctrl+C. + /// store the error but stay in loading view so the user can read it fn set_load_error(&mut self, error: String) { self.load_error = Some(error); // is_loading stays true so render_loading() keeps rendering. @@ -418,21 +379,19 @@ mod tui { self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len(); } - /// Total lines the messages area would need (rough estimate for scrolling). + /// rough line count for scroll math fn total_message_lines(&self, width: u16) -> u16 { if width == 0 { return 0; } - let w = width.saturating_sub(2) as usize; // subtract border columns + let w = width.saturating_sub(2) as usize; let mut lines: u16 = 0; for msg in &self.messages { lines += wrapped_line_count(&msg.text, msg.role, w); } - // count any in-progress streaming text too if !self.stream_buf.is_empty() { lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w); } - // thinking / switching indicator if self.thinking || self.switching_model { lines += 1; } @@ -449,7 +408,6 @@ mod tui { } } - /// How many terminal rows a message takes up after line-wrapping. fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 { let prefix_len = match role { Role::User => 6, // "you > " @@ -473,16 +431,14 @@ mod tui { count.max(1) } - // ── Model table ────────────────────────────────────────────────────────── + // ── Model picker ───────────────────────────────────────────────────────── // - // ModelSource, ModelPickerItem, and build_model_picker_items live in - // crate::models so they are available on all platforms (including Windows), - // not just unix where this chat module is compiled. + // picker data types live in crate::models so Windows (ACP-only) can use them too fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { let popup = centered_rect(82, 72, area); - // Erase whatever is behind the popup so the panel is fully readable. + // clear the background so text doesn't bleed through frame.render_widget(Clear, popup); let block = Block::default() @@ -665,7 +621,7 @@ mod tui { Help, Clear, Status, - /// `/models` opens the model picker. `/models N` still works as a shortcut. + /// picker UI, or jump straight to model N Models(Option<usize>), Exit, Unknown(String), @@ -695,7 +651,6 @@ mod tui { let area = frame.area(); if app.is_loading { - // Loading phase: title bar with spinner | loading info | footer hint. let zones = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), @@ -708,7 +663,6 @@ mod tui { return; } - // Normal chat phase: title | messages | input | footer. let zones = Layout::vertical([ Constraint::Length(1), Constraint::Min(1), @@ -825,7 +779,6 @@ mod tui { render_chat_message(&mut lines, msg, inner_width as usize); } - // In-progress streaming token buffer. if !app.stream_buf.is_empty() { let fake = ChatMessage { role: Role::Assistant, @@ -833,7 +786,6 @@ mod tui { think_block: None, }; render_chat_message(&mut lines, &fake, inner_width as usize); - // blinking cursor at end if app.blink_on && let Some(last) = lines.last_mut() { @@ -842,7 +794,6 @@ mod tui { } } - // Thinking / switching spinner. if app.thinking { lines.push(Line::from(Span::styled( format!(" {} thinking…", app.thinking_frame()), @@ -888,7 +839,6 @@ mod tui { fn render_chat_message(lines: &mut Vec<Line<'static>>, msg: &ChatMessage, _width: usize) { match msg.role { Role::Banner => { - // Each character in banner art gets its own rainbow colour. let palette = [ Color::Red, Color::Yellow, @@ -950,7 +900,6 @@ mod tui { } } Role::Assistant => { - // If there is a think block, render it first, dimmed. if let Some(ref think) = msg.think_block { lines.push(Line::from(Span::styled( " ┌ thinking ".to_string(), @@ -1005,7 +954,6 @@ mod tui { inner, ); - // Position the real terminal cursor inside the input box. let col = (app.cursor as u16) % inner.width; let row = (app.cursor as u16) / inner.width; frame.set_cursor_position(Position { @@ -1213,11 +1161,7 @@ mod tui { ..SamplingConfig::default() }; - // Use a dedicated OS thread with its own tokio Runtime - // so that load_gguf_model's internal block_in_place - // cannot steal the main runtime's worker threads and - // freeze the TUI draw loop. This mirrors the pattern - // used at startup in run_interactive / run_acp_server. + // own thread + runtime so block_in_place doesn't starve the TUI loop let system_prompt = crate::system_prompt_for_model(model.tool_calling); let engine_handle = Arc::clone(&engine); let tool_calling = model.tool_calling; @@ -1239,13 +1183,10 @@ mod tui { Err(err) => ModelLoadUpdate::Error(err.to_string()), } }); - // blocking_send is fine here — the channel has - // capacity 1 and the receiver is always alive while - // switching_model is true. + // capacity-1 channel, receiver alive while switching let _ = tx.blocking_send(update); }); - // tool_calling is applied when ModelLoadUpdate::Loaded - // arrives in the event loop (see model_load_rx handler). + // applied on ModelLoadUpdate::Loaded app.pending_tool_calling = Some(tool_calling); } } @@ -1263,10 +1204,9 @@ mod tui { // ── Background inference task ───────────────────────────────────────────── - /// Maximum number of tool-calling rounds before forcing a text response. + /// cap tool rounds so a confused model can't loop forever const MAX_TOOL_ROUNDS: usize = 10; - /// Build onde `ToolDefinition`s from our agent tools. fn build_onde_tools() -> Vec<ToolDefinition> { crate::tools::all_tools() .into_iter() @@ -1278,11 +1218,8 @@ mod tui { .collect() } - /// Runs the agentic tool-calling loop on a background task and sends - /// progress updates back through `tx`. - /// - /// The sender is dropped when the task finishes, which the event loop - /// detects as `None` from `rx.recv()`. + /// run the tool-calling loop off the main thread, posting updates via `tx`. + /// dropping `tx` signals completion to the event loop. async fn run_inference_task( engine: Arc<ChatEngine>, text: String, @@ -1318,12 +1255,10 @@ mod tui { tc.arguments.chars().take(120).collect::<String>() ); - // Notify the UI about the tool call. let _ = tx .send(InferenceUpdate::ToolUse(tc.function_name.clone())) .await; - // Execute the tool. let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments).await; log::info!(" ← {} chars", output.len()); @@ -1333,11 +1268,11 @@ mod tui { }); } - // Allow further tool calls unless we've hit the limit. + // on the last round, pass no tools so the model must produce text let next_tools = if round < MAX_TOOL_ROUNDS { Some(onde_tools.as_slice()) } else { - None // force a text response on the last round + None }; match engine.send_tool_results(tool_results, next_tools).await { @@ -1349,7 +1284,6 @@ mod tui { } } - // Send the final text response, or a fallback if the model returned nothing. if result.tool_calls.is_empty() { if result.text.is_empty() { log::warn!( @@ -1368,20 +1302,14 @@ mod tui { } log::info!("inference complete — {} tool round(s)", round); - // Sender drops here → event loop sees `None`. + // tx drops here — event loop gets None from rx.recv() } // ── Main loop ───────────────────────────────────────────────────────────── - /// Run the interactive chat UI. Blocks until the user quits. - /// - /// Accepts a terminal that has already been initialised by the caller — - /// [`ratatui::init`] and [`ratatui::restore`] are the caller's responsibility. - /// - /// `load_rx` is the receiving end of a [`std::sync::mpsc`] channel. A - /// dedicated OS thread loads the model and sends `Ok(())` or `Err(msg)` when - /// done. The event loop polls `try_recv()` on every tick — non-blocking, - /// zero contention with the tokio runtime. + /// entry point — blocks until the user quits. + /// caller owns terminal init/restore. `load_rx` delivers the model-load result + /// from a dedicated OS thread; we poll it non-blocking each tick. pub async fn run_with<B: ratatui::backend::Backend>( terminal: &mut ratatui::Terminal<B>, engine: Arc<ChatEngine>, @@ -1400,7 +1328,7 @@ mod tui { let mut app = App::new(load_model_name); let mut event_stream = EventStream::new(); - // 100 ms per tick ≈ 10 fps — enough for a smooth spinner. + // 10 fps is plenty for spinners let mut ticker = interval(Duration::from_millis(100)); loop { @@ -1521,7 +1449,7 @@ mod tui { app.finalize_stream(); } } - // Sender dropped without sending done=true. + // sender dropped without done=true None => { app.finalize_stream(); } @@ -1548,8 +1476,7 @@ mod tui { app.messages.push(ChatMessage::system(format!("error: {msg}"))); } None => { - // Sender dropped — task finished (possibly with no - // text response, e.g. all tool calls with empty final). + // task finished, possibly with no text to show app.stop_thinking(); } } @@ -1564,8 +1491,7 @@ mod tui { } } => { app.tick_thinking(); - // Refresh download-progress bytes from the HF cache dir so - // the progress bar in render_messages stays current. + // keep the progress display fresh if app.switching_model { app.poll_download_progress(); } @@ -1578,7 +1504,7 @@ mod tui { }; if let Event::Key(key) = event { - // During loading, only Ctrl+C / Ctrl+D are accepted. + // loading phase — only quit keys work if app.is_loading { if key.kind == KeyEventKind::Press { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); @@ -1592,7 +1518,7 @@ mod tui { continue; } - // While busy (streaming or thinking), only Ctrl+C/D work. + // busy — only cancel keys work if app.is_busy() { if key.kind == KeyEventKind::Press { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); @@ -1602,15 +1528,12 @@ mod tui { app.messages.push(ChatMessage::system("(cancelled)")); } if app.thinking { - // Drop the receiver — the background task - // will see a closed channel and stop. + // dropping rx kills the background task app.stop_thinking(); app.messages.push(ChatMessage::system("(cancelled)")); } if app.switching_model { - // Mark as cancelled before dropping the - // receiver so the Disconnected arm in the - // model_load_rx handler stays silent. + // flag before drop so Disconnected handler stays quiet app.model_load_cancelled = true; app.switching_model = false; app.switching_model_id = None; @@ -1630,7 +1553,7 @@ mod tui { continue; } - // ── Spawn inference on a background task ───────── + // ── spawn inference ────────────────────────────── app.messages.push(ChatMessage::user(&text)); app.start_thinking(); @@ -1654,8 +1577,7 @@ mod tui { // ── Download progress helpers (TUI) ────────────────────────────────────── - /// Recursively sum the on-disk size of all files under `path`, following - /// symlinks so hf-hub's blob layout is counted correctly. + /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks) fn dir_size_recursive(path: &std::path::Path) -> u64 { let mut total: u64 = 0; let Ok(entries) = std::fs::read_dir(path) else { @@ -1672,7 +1594,6 @@ mod tui { total } - /// Format a byte count as a terse human-readable string. fn format_size_human(bytes: u64) -> String { const GB: u64 = 1_073_741_824; const MB: u64 = 1_048_576; @@ -1689,9 +1610,7 @@ mod tui { } } // end #[cfg(unix)] mod tui -// Re-export the Unix-only public entry point so callers can write -// `chat::run_with(...)` on all platforms and get a clean "not available" -// compile error on Windows rather than a missing-item error. +// re-export so callers write `chat::run_with(...)` on all platforms #[cfg(unix)] pub use tui::run_with;
src/main.rs
+81 -161
index ac802fa..ef08295 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,29 +1,21 @@ -//! siGit Code is a local coding agent built on Onde Inference. +//! siGit Code — local coding agent on Onde Inference. //! -//! When you run it in an interactive terminal, all process output goes to -//! `$TMPDIR/sigit.log` first. That includes `log::` events, `tracing` output -//! from mistralrs_core, and even stray `println!` calls from dependencies. -//! Ratatui gets its own copy of the real terminal handle, so the UI can keep -//! drawing normally while the noisy stuff goes to the log file. +//! In TTY mode, all output (log crate, tracing, stray printlns) redirects to +//! `$TMPDIR/sigit.log`. Ratatui holds a separate fd to the real terminal so +//! the TUI stays clean. //! -//! siGit has two modes: -//! - ACP mode over stdio for editors like Zed -//! - interactive terminal mode when you run it directly in a TTY +//! Two modes: +//! - ACP over stdio (editor integration, e.g. Zed) +//! - interactive terminal (direct TTY) //! -//! Current platform support: -//! - macOS: ACP mode and interactive terminal mode -//! - Linux: ACP mode and interactive terminal mode -//! - Windows: ACP mode only for now +//! Interactive mode is Unix-only — it needs fd redirection to keep logs out +//! of the TUI. Windows only gets ACP mode for now. //! -//! The interactive terminal path is still Unix-only because it relies on -//! Unix file-descriptor redirection to keep logs away from the TUI. +//! The model loads before the ACP `LocalSet` starts because `mistralrs` calls +//! `block_in_place`, which panics inside `spawn_local`. Loading on a regular +//! multi-thread worker sidesteps that. //! -//! The model loads before the ACP `LocalSet` starts. That is important because -//! `mistralrs` calls `block_in_place` internally, and that blows up inside -//! `spawn_local` tasks. Loading it on a normal multi-thread worker avoids the -//! problem. -//! -//! On macOS, the HF cache points at the App Group container shared with the +//! On macOS the HF cache lives in the App Group container shared with the //! siGit desktop app. See [`setup`]. //! //! # Zed setup @@ -194,11 +186,8 @@ specific and practical. Be direct and brief. Write clean, idiomatic code. When debugging, go for the \ root cause, not the symptom. Correct beats clever."; -/// Slim system prompt for models that do not support tool calling. -/// -/// These models (e.g. DeepSeek Coder v1) cannot use the agent tools, so -/// the long tool-oriented instructions in [`SYSTEM_PROMPT`] would waste -/// context and confuse the model. Keep this short and code-focused. +/// shorter prompt for models without tool calling (e.g. DeepSeek Coder v1). +/// the full [`SYSTEM_PROMPT`] wastes context and confuses them. const SIMPLE_SYSTEM_PROMPT: &str = "\ Your name is siGit — a coding assistant. \ You are helpful, concise, and write clean, idiomatic code. \ @@ -206,7 +195,6 @@ Answer any question the user asks — programming, general knowledge, or casual When debugging, address the root cause, not the symptom. \ Be direct and brief."; -/// Pick the right system prompt based on whether the model supports tool calling. pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str { if tool_calling { SYSTEM_PROMPT @@ -215,10 +203,9 @@ pub(crate) fn system_prompt_for_model(tool_calling: bool) -> &'static str { } } -/// Maximum number of tool-calling rounds before forcing a text response. +/// cap tool-call loops so a confused model can't spin forever const MAX_TOOL_ROUNDS: usize = 10; -/// Convert the agent tool definitions into onde's `ToolDefinition` type. fn agent_tools_as_onde() -> Vec<ToolDefinition> { tools::all_tools() .into_iter() @@ -278,16 +265,11 @@ fn initialize_meta() -> Meta { meta } -// Agent - struct SiGitAgent { engine: Arc<ChatEngine>, notification_tx: mpsc::Sender<SessionNotification>, - /// The project working directory provided by the editor via ACP session - /// creation. Tool calls use this as `cwd` so file operations target the - /// correct project, not wherever the agent process was spawned. + /// cwd from the editor — tool calls run here, not where the process started session_cwd: std::sync::Mutex<Option<PathBuf>>, - /// The currently loaded model config, used for config_options reporting. current_model: std::sync::Mutex<GgufModelConfig>, } @@ -344,12 +326,8 @@ impl SiGitAgent { ..SamplingConfig::default() }; - // load_gguf_model calls block_in_place internally. Calling it from - // inside the ACP LocalSet (spawn_local) panics with "can call blocking - // only when running on the multi-threaded runtime". Fix: run the - // unload + load on a dedicated OS thread with its own runtime, then - // await the result over a oneshot channel — same pattern used at - // startup in run_acp_server. + // block_in_place inside spawn_local panics, so run the load on a + // dedicated thread with its own runtime (same trick as startup) let (result_tx, result_rx) = tokio::sync::oneshot::channel::<Result<(), String>>(); let loader_engine = Arc::clone(&self.engine); let loader_config = new_config.clone(); @@ -359,10 +337,8 @@ impl SiGitAgent { std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime"); let result = rt.block_on(async move { - // load_gguf_model unloads any existing model internally before - // loading the new one. Calling unload_model() explicitly first - // would create a window where no model is loaded — if a prompt - // arrived in that gap it would fail with NoModelLoaded. + // load_gguf_model already unloads the old model internally; + // calling unload first would leave a gap where prompts fail loader_engine .load_gguf_model( loader_config, @@ -414,10 +390,9 @@ impl SiGitAgent { } } -/// The config option ID used for the model selector in the Zed agent panel. +/// config option ID for the model picker in Zed's agent panel const MODEL_CONFIG_ID: &str = "sigit-model"; -/// Build the `SessionConfigOption` list for model selection. fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionConfigOption> { let items = models::build_model_picker_items(); @@ -465,9 +440,7 @@ fn build_model_config_options(current_model: &GgufModelConfig) -> Vec<SessionCon ] } -/// Look up the GgufModelConfig for a given model_id value from the picker items. -/// -/// Returns `(config, max_tokens, tool_calling)`. +/// returns `(config, max_tokens, tool_calling)` for a picker model_id, or None fn resolve_model_config(model_id: &str) -> Option<(GgufModelConfig, u64, bool)> { let items = models::build_model_picker_items(); items @@ -760,24 +733,20 @@ impl Agent for SiGitAgent { .collect::<Vec<_>>() ); - // Capture the project working directory from the editor. if let Ok(mut guard) = self.session_cwd.lock() { *guard = Some(args.cwd.clone()); } - // Set the process cwd so tool calls using relative paths land in the - // correct project directory. + // tool calls use relative paths, so we need to match the editor's cwd if args.cwd.is_dir() && let Err(err) = std::env::set_current_dir(&args.cwd) { log::warn!("could not set cwd to {}: {err}", args.cwd.display()); } - // Clear conversation history — siGit doesn't persist sessions, so a - // "load" is effectively a fresh start with the same session ID. + // no session persistence, so "load" just resets self.engine.clear_history().await; - // Tell the model which project directory it's working in. self.engine .push_history(onde::inference::ChatMessage::system(format!( "The user's project working directory is {}. \ @@ -811,7 +780,6 @@ impl Agent for SiGitAgent { .collect::<Vec<_>>() ); - // Update cwd if the fork provides a different one. if let Ok(mut guard) = self.session_cwd.lock() { *guard = Some(args.cwd.clone()); } @@ -821,9 +789,7 @@ impl Agent for SiGitAgent { log::warn!("could not set cwd to {}: {err}", args.cwd.display()); } - // siGit doesn't persist history, so a fork is effectively a fresh - // session — clear the conversation and let the user start over from - // their edited message. + // no persistence, so fork == fresh session self.engine.clear_history().await; self.engine @@ -858,7 +824,6 @@ impl Agent for SiGitAgent { .collect::<Vec<_>>() ); - // Capture the project working directory from the editor. if let Ok(mut guard) = self.session_cwd.lock() { *guard = Some(args.cwd.clone()); } @@ -868,7 +833,6 @@ impl Agent for SiGitAgent { log::warn!("could not set cwd to {}: {err}", args.cwd.display()); } - // Clear history — the model is already loaded. self.engine.clear_history().await; self.engine @@ -892,8 +856,7 @@ impl Agent for SiGitAgent { async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result<PromptResponse> { let session_id = args.session_id.clone(); - // Debug: log every content block the editor sends so we can see - // exactly what arrives for @ references, file context, etc. + // log every block so we can debug @ references and file context for (i, block) in args.prompt.iter().enumerate() { match block { ContentBlock::Text(t) => { @@ -949,7 +912,7 @@ impl Agent for SiGitAgent { parts.push(t.text.clone()); } ContentBlock::Resource(embedded) => { - // Embedded file content sent by the editor (preferred over ResourceLink). + // editor inlined the file content already match &embedded.resource { agent_client_protocol::EmbeddedResourceResource::TextResourceContents( text_resource, @@ -970,11 +933,10 @@ impl Agent for SiGitAgent { } } ContentBlock::ResourceLink(link) => { - // The editor sent a reference but not the content — read it if it's a file. + // reference without content; read the file ourselves let label = link.name.clone(); if let Some(raw_path) = link.uri.strip_prefix("file://") { - // Split off the #L<start>:<end> fragment if present. let (file_path, line_range) = if let Some(hash_pos) = raw_path.rfind('#') { let fragment = &raw_path[hash_pos + 1..]; let path = &raw_path[..hash_pos]; @@ -994,7 +956,6 @@ impl Agent for SiGitAgent { match std::fs::read_to_string(file_path) { Ok(contents) => { let extracted = if let Some((start, end)) = line_range { - // Extract only the requested line range (1-based, inclusive). let selected: Vec<&str> = contents .lines() .enumerate() @@ -1046,13 +1007,9 @@ impl Agent for SiGitAgent { user_text.chars().take(80).collect::<String>() ); - // ── Agentic tool-calling loop ──────────────────────────────────── - // - // 1. Send the user message with tool definitions (non-streaming). - // 2. If the model responds with tool calls, execute them, feed - // results back, and repeat (up to MAX_TOOL_ROUNDS). - // 3. Once the model produces a text response (no tool calls), - // stream it to the editor. + // ── tool-calling loop ──────────────────────────────────────────── + // send message → execute any tool calls → feed results back + // repeat up to MAX_TOOL_ROUNDS, then force a text reply let onde_tools = agent_tools_as_onde(); @@ -1085,7 +1042,6 @@ impl Agent for SiGitAgent { tc.arguments.chars().take(120).collect::<String>() ); - // Execute the tool (async — read_website uses spawn_blocking internally). let output = tools::execute_tool(&tc.function_name, &tc.arguments).await; log::info!(" ← {} chars", output.len()); @@ -1096,11 +1052,10 @@ impl Agent for SiGitAgent { }); } - // Decide whether to allow further tool calls. let next_tools = if round < MAX_TOOL_ROUNDS { Some(onde_tools.as_slice()) } else { - None // force a text response on the last round + None // last round: force text }; result = self @@ -1129,8 +1084,7 @@ impl Agent for SiGitAgent { String::new() } } else { - // Strip Qwen 3 `<think>…</think>` blocks — the editor doesn't - // need to see internal reasoning tokens. + // strip <think> blocks so reasoning tokens stay hidden let (_think, visible) = chat::strip_think_blocks(&reply_text); visible }; @@ -1173,21 +1127,18 @@ impl Agent for SiGitAgent { let model_id = args.value.0.as_ref(); - // Check if this model needs to be downloaded first so we can show - // a progress indicator in Zed while the download + load is happening. let needs_download = models::build_model_picker_items() .into_iter() .find(|item| item.config.model_id == model_id) .map(|item| item.cache_health == setup::ModelCacheHealth::NotDownloaded) .unwrap_or(false); - // Spawn a progress-poller task that sends periodic download status - // messages to Zed via the notification channel. A shared flag lets - // us stop the poller once the load finishes. + // shared flag to kill the progress poller when the load finishes let stop_flag = Arc::new(AtomicBool::new(false)); + let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4()); + if needs_download { - // Send the initial "downloading" banner immediately. let model_id_owned = model_id.to_string(); let expected_bytes = onde::inference::models::SUPPORTED_MODEL_INFO .iter() @@ -1207,14 +1158,12 @@ impl Agent for SiGitAgent { String::new() }; - let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4()); - self.send_tool_call_update( args.session_id.clone(), SessionUpdate::ToolCall( ToolCall::new( tool_call_id.clone(), - format!("Downloading {display_name}{size_hint}"), + format!("⏬ Downloading {display_name}{size_hint}"), ) .kind(ToolKind::Execute) .status(ToolCallStatus::InProgress) @@ -1228,7 +1177,7 @@ impl Agent for SiGitAgent { ) .await; - // Poller: every 4 seconds report bytes-on-disk / expected. + // poll download progress and update the spinner in Zed let poller_tx = self.notification_tx.clone(); let poller_session = args.session_id.clone(); let poller_model_id = model_id_owned.clone(); @@ -1236,8 +1185,10 @@ impl Agent for SiGitAgent { let poller_tool_call_id = tool_call_id.clone(); tokio::task::spawn_local(async move { + const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; let cache_path = onde::hf_cache::model_cache_path(&poller_model_id); - let mut interval = tokio::time::interval(std::time::Duration::from_secs(4)); + let mut tick: usize = 0; + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); interval.tick().await; // consume the immediate first tick while !poller_stop.load(Ordering::Relaxed) { @@ -1253,6 +1204,17 @@ impl Agent for SiGitAgent { .map(|p| dir_size_recursive(p)) .unwrap_or(0); + let frame = SPINNER[tick % SPINNER.len()]; + tick += 1; + + let title = if expected_bytes > 0 { + let pct = + ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8; + format!("{frame} Downloading {display_name}{size_hint} ({pct}%)") + } else { + format!("{frame} Downloading {display_name}{size_hint}") + }; + let msg = if expected_bytes > 0 { let pct = ((downloaded as f64 / expected_bytes as f64) * 100.0).min(99.0) as u8; @@ -1274,6 +1236,7 @@ impl Agent for SiGitAgent { SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( poller_tool_call_id.clone(), ToolCallUpdateFields::new() + .title(title) .status(ToolCallStatus::InProgress) .content(vec![msg.into()]), )), @@ -1285,10 +1248,7 @@ impl Agent for SiGitAgent { }); } - // For already-cached models, send a "loading" message and a spinner - // so the user sees activity while mistralrs loads the weights (~10-30 s). - let tool_call_id = format!("model-switch-{}", uuid::Uuid::new_v4()); - + // cached models still take 10-30s to load weights; show a spinner if !needs_download { let cached_display_name = models::build_model_picker_items() .into_iter() @@ -1310,8 +1270,7 @@ impl Agent for SiGitAgent { ) .await; - // Spinner poller: send an elapsed-time update every 5 seconds so - // the user can tell siGit is still working. + // tick every 5s so the user knows we haven't frozen let spinner_tx = self.notification_tx.clone(); let spinner_session = args.session_id.clone(); let spinner_name = cached_display_name.clone(); @@ -1360,15 +1319,14 @@ impl Agent for SiGitAgent { let switch_result = self.switch_model_by_id(model_id).await; - // Stop the progress / spinner poller regardless of success/failure. stop_flag.store(true, Ordering::Relaxed); match switch_result { Ok(new_config) => { let completion_title = if needs_download { - format!("{} downloaded and loaded", new_config.display_name) + format!("✓ {} downloaded and loaded", new_config.display_name) } else { - format!("Switched to {}", new_config.display_name) + format!("✓ Switched to {}", new_config.display_name) }; let completion_body = if needs_download { format!("✓ {} downloaded and loaded.", new_config.display_name) @@ -1417,9 +1375,8 @@ impl Agent for SiGitAgent { // ── Download progress helpers ───────────────────────────────────────────────── -/// Recursively sum the sizes of all files under `path`, following symlinks. -/// Used by the ACP download-progress poller to report bytes-on-disk before -/// hf-hub renames the staging files to their final blob names. +/// total bytes on disk under `path`. needed because hf-hub uses staging +/// names during download, so we can't just stat the final blobs. fn dir_size_recursive(path: &std::path::Path) -> u64 { let mut total: u64 = 0; let Ok(entries) = std::fs::read_dir(path) else { @@ -1436,7 +1393,6 @@ fn dir_size_recursive(path: &std::path::Path) -> u64 { total } -/// Format a byte count as a human-readable string (B / KB / MB / GB). fn format_size_human(bytes: u64) -> String { const GB: u64 = 1_073_741_824; const MB: u64 = 1_048_576; @@ -1452,8 +1408,6 @@ fn format_size_human(bytes: u64) -> String { } } -/// Build a simple ASCII progress bar string of the given width. -/// e.g. `[████████░░░░░░░░░░░░]` at 40 % fn progress_bar(pct: u8, width: usize) -> String { let filled = ((pct as usize) * width) / 100; let empty = width.saturating_sub(filled); @@ -1462,28 +1416,17 @@ fn progress_bar(pct: u8, width: usize) -> String { // ── Output capture ──────────────────────────────────────────────────────────── -/// Redirect **both** stdout and stderr to `$TMPDIR/sigit.log` at the -/// file-descriptor level and return a [`std::fs::File`] handle to the *real* -/// terminal (the original stdout) so ratatui can still render to it. -/// -/// This is the nuclear option — it catches absolutely everything that any -/// library writes to stdout (`println!` in mistralrs `print_metadata`) or -/// stderr (`tracing::info!`, `log::info!`, raw `eprintln!`). -/// -/// Returns **two** `File` handles to the real terminal (both created via -/// `dup(STDOUT)` *before* the redirect): -/// -/// 1. **`tui`** — given to ratatui's `CrosstermBackend` for rendering. -/// 2. **`cleanup`** — kept by the caller for writing `LeaveAlternateScreen` -/// and restoring stdout/stderr after the TUI exits (since ratatui 0.29 -/// does not expose `writer_mut()` on the backend). +/// redirect stdout+stderr to `$TMPDIR/sigit.log` at the fd level so +/// mistralrs/tracing noise never hits the terminal. returns two dup'd +/// fds to the real tty: one for ratatui, one for cleanup (ratatui 0.29 +/// doesn't expose `writer_mut()`). #[cfg(unix)] fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> { let log_path = std::env::temp_dir().join("sigit.log"); let log_file = std::fs::File::create(&log_path)?; let log_fd = log_file.as_raw_fd(); - // Save TWO copies of the real terminal fd before we clobber stdout. + // two copies: ratatui needs one, cleanup needs another let saved_tui = unsafe { libc::dup(libc::STDOUT_FILENO) }; anyhow::ensure!( saved_tui >= 0, @@ -1497,14 +1440,12 @@ fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> { std::io::Error::last_os_error() ); - // Point stdout and stderr at the log file. unsafe { libc::dup2(log_fd, libc::STDOUT_FILENO); libc::dup2(log_fd, libc::STDERR_FILENO); } - // `log_file` can drop — dup2 created independent references to the - // underlying file description, so stdout/stderr keep it alive. + // safe to drop log_file; dup2 keeps the fd alive via stdout/stderr Ok((unsafe { std::fs::File::from_raw_fd(saved_tui) }, unsafe { std::fs::File::from_raw_fd(saved_cleanup) @@ -1513,11 +1454,8 @@ fn redirect_output_to_log() -> anyhow::Result<(std::fs::File, std::fs::File)> { // ── Logging ─────────────────────────────────────────────────────────────────── -/// Initialise `tracing-subscriber` as the single logging backend. -/// -/// In TUI mode stdout/stderr have already been redirected to the log file by -/// [`redirect_output_to_log`], so the subscriber simply writes to stderr -/// (which *is* the log file). In ACP mode stderr is the real stderr. +/// in TUI mode stderr is the log file (redirected earlier); +/// in ACP mode it's real stderr. either way, write there. fn init_logging(is_tty: bool) { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let _ = tracing_fmt::Subscriber::builder() @@ -1529,19 +1467,9 @@ fn init_logging(is_tty: bool) { // ── Interactive TUI mode ────────────────────────────────────────────────────── -/// Start the TUI immediately, load the model concurrently, signal completion -/// via a oneshot channel so the TUI can animate the banner while waiting. -/// -/// The terminal is set up *manually* against the saved real-terminal `File` -/// returned by [`redirect_output_to_log`]. Because stdout/stderr have -/// already been redirected to the log file at that point, any `println!`, -/// `eprintln!`, `log::info!`, or `tracing::info!` emitted by mistralrs or -/// onde goes straight to `$TMPDIR/sigit.log` and never touches the screen. -/// -/// `tty` is given to ratatui; `cleanup_tty` is a second fd to the same -/// terminal, used for `LeaveAlternateScreen` and restoring stdout/stderr -/// (we cannot access the backend's writer because `writer_mut()` is private -/// in ratatui 0.29). +/// boot the TUI and load the model on a background thread. +/// `tty` goes to ratatui; `cleanup_tty` is a separate fd for +/// LeaveAlternateScreen (ratatui 0.29 hides `writer_mut()`). #[cfg(unix)] async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> { let engine = Arc::new(ChatEngine::new()); @@ -1550,7 +1478,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> let startup_model_name = startup_selection .as_ref() .map(|selection| selection.display_name.clone()) - .unwrap_or_else(|| GgufModelConfig::platform_default().display_name); + .unwrap_or_else(|| GgufModelConfig::qwen3_4b().display_name); let config = startup_selection .as_ref() @@ -1573,14 +1501,13 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> }) .map(|item| item.config) }) - .unwrap_or_else(GgufModelConfig::platform_default); + .unwrap_or_else(GgufModelConfig::qwen3_4b); let sampling = SamplingConfig { max_tokens: Some(8192), ..SamplingConfig::default() }; - // std::sync::mpsc — the loader runs on a dedicated OS thread, completely - // decoupled from the tokio runtime so it can't starve the TUI draw loop. + // std::sync::mpsc on a real thread so model loading can't starve the TUI let (load_tx, load_rx) = std::sync::mpsc::channel::<Result<(), String>>(); let loader_engine = Arc::clone(&engine); @@ -1597,24 +1524,21 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string())); }); - // Set up the terminal manually on the real tty fd. crossterm::terminal::enable_raw_mode()?; let mut tty = BufWriter::new(tty); crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?; let backend = ratatui::backend::CrosstermBackend::new(tty); let mut terminal = ratatui::Terminal::new(backend)?; - // The TUI runs here on the main tokio runtime. It polls load_rx via - // try_recv() on every tick — non-blocking, zero contention. + // polls load_rx with try_recv() each tick, no blocking let chat_result = chat::run_with(&mut terminal, engine, load_rx, startup_model_name).await; - // Restore the terminal before exiting. - // Use the separate cleanup fd — the backend's writer is private. + // cleanup fd because backend's writer is private crossterm::execute!(cleanup_tty, crossterm::terminal::LeaveAlternateScreen)?; cleanup_tty.flush()?; crossterm::terminal::disable_raw_mode()?; - // Restore stdout/stderr so any post-TUI error messages are visible. + // restore real stdout/stderr for post-TUI error output #[cfg(unix)] { let cleanup_fd = cleanup_tty.as_raw_fd(); @@ -1632,8 +1556,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> async fn run_acp_server() -> anyhow::Result<()> { log::info!("ACP mode — starting agent server"); - // Load before the LocalSet. block_in_place panics inside spawn_local, - // so the model must load on a regular worker thread. + // must load before LocalSet: block_in_place panics inside spawn_local log::info!("loading model (this may take a minute on first run)..."); let engine = Arc::new(ChatEngine::new()); @@ -1690,11 +1613,11 @@ async fn run_acp_server() -> anyhow::Result<()> { let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256); let agent = SiGitAgent::new(engine, notification_tx, startup_config); - // AgentSideConnection wants futures-io, not tokio-io. + // AgentSideConnection needs futures-io let stdin = tokio::io::stdin().compat(); let stdout = tokio::io::stdout().compat_write(); - // ACP futures are !Send — needs a LocalSet. + // ACP futures are !Send let local = tokio::task::LocalSet::new(); local @@ -1708,7 +1631,6 @@ async fn run_acp_server() -> anyhow::Result<()> { }, ); - // Forward streamed chunks to the editor. tokio::task::spawn_local(async move { while let Some(notification) = notification_rx.recv().await { if let Err(err) = conn.session_notification(notification).await { @@ -1717,7 +1639,6 @@ async fn run_acp_server() -> anyhow::Result<()> { } }); - // Runs until the editor disconnects. if let Err(err) = io_task.await { log::error!("ACP IO error: {err}"); } @@ -1735,8 +1656,7 @@ async fn main() -> anyhow::Result<()> { let is_tty = std::io::stdin().is_terminal(); if is_tty { - // Redirect stdout/stderr to $TMPDIR/sigit.log *first* — before any - // library code can println!/eprintln!/log to the real terminal. + // must redirect before any library code touches stdout #[cfg(unix)] { let (tty, cleanup_tty) = redirect_output_to_log()?;
src/models.rs
+7 -35
index 3f4d4c7..74b44ae 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,8 +1,5 @@ -//! Platform-independent model picker types and item construction. -//! -//! This module is available on all target platforms (Windows, macOS, Linux). -//! The TUI rendering code in `chat.rs` (unix-only) re-uses these types -//! rather than defining them inline. +//! Model picker types and item construction, shared across platforms. +//! The unix-only TUI in `chat.rs` pulls from here. use onde::inference::GgufModelConfig; @@ -16,8 +13,7 @@ pub(crate) use crate::setup::ModelCacheHealth; pub(crate) enum ModelSource { Onde, HuggingFace, - /// Supported model that is not yet downloaded locally. When selected it - /// will be downloaded into the Onde app-group cache automatically. + /// not downloaded yet — selecting it triggers a download into the app-group cache. Available, Fallback, } @@ -37,9 +33,7 @@ pub(crate) struct ModelPickerItem { // ── Model ID → GgufModelConfig mapping ──────────────────────────────────────── -/// Map a HuggingFace model ID to the corresponding [`GgufModelConfig`] -/// constructor. Returns `None` for model IDs that siGit does not know how -/// to load. +/// map a HF model ID to its config constructor, or `None` if we don't support it. pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> { Some(match model_id { "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(), @@ -56,7 +50,6 @@ pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> { }) } -/// Whether a model ID supports tool calling (Qwen 3 family). fn is_tool_calling(model_id: &str) -> bool { matches!( model_id, @@ -68,29 +61,15 @@ fn is_tool_calling(model_id: &str) -> bool { ) } -/// Max tokens for a given model (tool-calling models need higher budgets -/// because the `<think>…</think>` block consumes tokens before the real -/// response). +/// tool-calling models get more tokens because `<think>` blocks eat into the budget. fn max_tokens_for(model_id: &str) -> u64 { if is_tool_calling(model_id) { 4096 } else { 512 } } // ── Builder ─────────────────────────────────────────────────────────────────── -/// Build the full list of model picker items. -/// -/// Items are sourced from: -/// 1. **Locally cached** models in the Onde app-group and HuggingFace caches. -/// 2. **All supported models** from [`onde::inference::models::SUPPORTED_MODEL_INFO`] -/// that are not yet downloaded locally — shown as `Available` so the user -/// can select them to trigger a download into the app-group cache. -/// -/// If no models are discovered *and* no supported models are known, a single -/// fallback entry for the platform-default model is returned so the picker -/// is never empty. -/// -/// Items are sorted: Onde first, then HuggingFace, then Available (not -/// downloaded), then Fallback, and alphabetically within each group. +/// collect every model the picker should show: local cache, remote available, fallback. +/// sorted by source (Onde > HF > Available > Fallback), then alphabetically. pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> { let mut items = Vec::new(); @@ -102,13 +81,6 @@ pub(crate) fn build_model_picker_items() -> Vec<ModelPickerItem> { } // ── 2. Supported models not yet downloaded ─────────────────────────── - // - // Walk SUPPORTED_MODEL_INFO and add an entry for every model ID that - // does not already appear in the local items list (by model_id). - // These entries have `cache_health: NotDownloaded` and - // `source: Available`. When the user selects one, `load_gguf_model` - // will download the GGUF file from HuggingFace into the app-group - // cache automatically. for info in onde::inference::models::SUPPORTED_MODEL_INFO { let already_present = items.iter().any(|item| item.config.model_id == info.id); if already_present {
src/setup.rs
+32 -67
index a26af72..f57c798 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,39 +1,25 @@ -//! Shared model cache setup, local model discovery, and lightweight local -//! preferences. +//! Model cache setup, local model discovery, and selected-model persistence. //! -//! On macOS, siGit desktop and other Onde apps keep their HuggingFace models -//! in a shared App Group container at: +//! On macOS the CLI shares a HuggingFace cache with Onde desktop apps via an +//! App Group container (`~/Library/Group Containers/group.com.ondeinference.apps/models/`). +//! On other platforms it falls back to `~/.cache/huggingface/`. //! -//! `~/Library/Group Containers/group.com.ondeinference.apps/models/` -//! -//! This module points `HF_HOME` / `HF_HUB_CACHE` there so the CLI reuses -//! whatever the desktop app already downloaded (and vice versa). On Linux -//! and Windows the default `~/.cache/huggingface/` path is used. -//! -//! It also exposes helpers for finding locally available models. Discovery -//! checks the Onde app group first on macOS, then falls back to the normal -//! Hugging Face cache layout. -//! -//! The selected model name is persisted in a small local preferences file so -//! the interactive UI can restore the last choice on the next launch. -//! -//! Call this before anything touches `ChatEngine` or `hf-hub` — they read -//! the env vars once at init and never check again. +//! Must run before anything touches `ChatEngine` or `hf-hub` because they +//! read the env vars once at init. use std::path::{Path, PathBuf}; -/// App Group ID shared across all Onde apps (siGit, Rumi, GT8, …). +/// shared across siGit, Rumi, GT8, etc. #[cfg(target_os = "macos")] const APP_GROUP_IDENTIFIER: &str = "group.com.ondeinference.apps"; -/// Find the shared container and set `HF_HOME` / `HF_HUB_CACHE` to point -/// there. Skips any var the user already set. +/// point `HF_HOME` / `HF_HUB_CACHE` at the shared container. no-ops if +/// the user already set them. pub fn setup_shared_model_cache() { if let Some(shared_dir) = resolve_shared_container() { let models_home = shared_dir.join("models"); let model_hub = models_home.join("hub"); - // Make sure the dirs exist. if let Err(error) = std::fs::create_dir_all(&model_hub) { log::warn!( "Failed to create shared model cache at {}: {error} — falling back to default", @@ -42,7 +28,6 @@ pub fn setup_shared_model_cache() { return; } - // hf-hub derives all its paths from HF_HOME. if std::env::var("HF_HOME").is_err() { // SAFETY: called once at startup before any threads are spawned. unsafe { std::env::set_var("HF_HOME", &models_home) }; @@ -54,8 +39,7 @@ pub fn setup_shared_model_cache() { ); } - // Some mistral.rs code paths read HF_HUB_CACHE directly instead - // of deriving it from HF_HOME, so we set both. + // mistral.rs reads HF_HUB_CACHE directly instead of deriving from HF_HOME if std::env::var("HF_HUB_CACHE").is_err() { // SAFETY: called once at startup before any threads are spawned. unsafe { std::env::set_var("HF_HUB_CACHE", &model_hub) }; @@ -66,15 +50,14 @@ pub fn setup_shared_model_cache() { } } -/// Preference key used to remember the last selected model. const SELECTED_MODEL_FILE_NAME: &str = "selected-model.txt"; -/// Stable persisted identifier for a selected local model. +/// persisted identifier for a selected model (model_id + gguf filename). #[derive(Debug, Clone, PartialEq, Eq)] pub struct SelectedModel { - /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`. + /// e.g. `bartowski/Qwen_Qwen3-4B-GGUF` pub model_id: String, - /// GGUF filename inside the snapshot. + pub gguf_file: String, } @@ -91,31 +74,31 @@ impl SelectedModel { } } -/// Minimal startup model selection info used before the full UI is running. +/// what we know about the model before the full UI is up. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StartupModelSelection { - /// Human-friendly model name shown in the loading UI. + /// shown in the loading screen pub display_name: String, - /// The saved model identifier if one was found. + pub selected_model: Option<SelectedModel>, } -/// A locally discovered GGUF model candidate. +/// a GGUF model found on disk. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DiscoveredModel { - /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`. + /// e.g. `bartowski/Qwen_Qwen3-4B-GGUF` pub model_id: String, - /// GGUF filename inside the snapshot. + /// filename inside the snapshot dir pub gguf_file: String, - /// Human-friendly label shown in model pickers. + pub display_name: String, - /// Absolute path to the snapshot directory that contains the GGUF file. + pub snapshot_path: PathBuf, - /// Absolute path to the GGUF file itself. + pub gguf_path: PathBuf, - /// True when the model came from the Onde app group cache. + pub from_app_group: bool, - /// Whether the snapshot looks complete enough to load. + pub cache_health: ModelCacheHealth, } @@ -126,11 +109,7 @@ pub enum ModelCacheHealth { NotDownloaded, } -/// Return all locally discovered GGUF models. -/// -/// Search order: -/// 1. Onde app group cache on macOS -/// 2. Standard Hugging Face cache +/// find all GGUF models on disk. checks Onde app group first, then HF cache. pub fn discover_local_models() -> Vec<DiscoveredModel> { let mut models = Vec::new(); let mut seen_roots = Vec::new(); @@ -243,18 +222,14 @@ fn collect_models_from_cache_root( } if gguf_files.is_empty() { - // No GGUF file found — the snapshot exists on disk (e.g. only - // metadata arrived, or the download is still in progress). - // Push a sentinel entry with Incomplete health so the model - // picker can show it as disabled rather than hiding it entirely. + // snapshot dir exists but no .gguf yet (download in progress or + // only metadata). mark incomplete so the picker can show it disabled. models.push(DiscoveredModel { display_name: display_name_for_model(&model_id, ""), model_id: model_id.clone(), gguf_file: String::new(), snapshot_path: snapshot_path.clone(), - // Point at the snapshot directory itself; this path is - // never used for loading because Incomplete models are - // filtered out before any GgufModelConfig is built. + // unused for loading; incomplete models are filtered out before config gguf_path: snapshot_path.clone(), from_app_group, cache_health: ModelCacheHealth::Incomplete, @@ -355,16 +330,8 @@ pub fn load_selected_model_name() -> Option<String> { .map(|model| model.display_name) } -/// Pick the model name siGit should try to load at startup. -/// -/// Order: -/// 1. saved selection, if it still exists locally -/// 2. first discovered local model (Onde app group first, then HF cache) -/// 3. no selection -/// -/// If there is no saved selection but a local model is discovered, persist that -/// fallback choice so ACP mode and the interactive TUI converge on the same -/// startup model on the next launch too. +/// pick a model for startup: saved selection > first local model > none. +/// if we fall back to a local model, persist it so ACP and TUI agree next time. pub fn startup_model_selection() -> Option<StartupModelSelection> { let discovered = discover_local_models(); @@ -433,10 +400,8 @@ fn selected_model_file_path() -> Option<PathBuf> { ) } -/// Look for the App Group container on disk. macOS creates it the first time -/// a signed app in the group accesses it, so it only exists if the user has -/// launched siGit desktop (or another Onde app) at least once. A plain CLI -/// binary can read/write there without extra entitlements. +/// macOS only creates this dir when a signed app in the group first runs, +/// so it won't exist until the user has launched siGit desktop or another Onde app. #[cfg(target_os = "macos")] fn resolve_shared_container() -> Option<PathBuf> { let home = std::env::var("HOME").ok()?;
src/tools.rs
+8 -88
index a2d8a63..38d803c 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1,31 +1,4 @@ -//! Tool definitions and execution for the siGit Code. -//! -//! Each tool has: -//! - A schema (JSON Schema) that describes its parameters for the LLM -//! - An execution function that runs the tool and returns a string result -//! -//! # Dependencies -//! -//! This module requires `serde_json` and `regex` crates in `Cargo.toml`: -//! ```toml -//! serde_json = "1" -//! regex = "1" -//! ``` -//! -//! # Write Tools -//! -//! - `create_directory` — create a directory and any missing parent directories -//! - `create_file` — create a new file (fails if it already exists) -//! - `edit_file` — replace an exact old-text span with new text in an existing file -//! - `delete_file` — delete a file or empty directory at the given path -//! -//! # Web Tools -//! -//! - `read_website` — fetch a web page and return readable text content -//! -//! # Shell Tools -//! -//! - `run_command` — run shell commands, including git porcelain and plumbing commands +//! Agent tools: schema definitions + execution for siGit Code. use regex::Regex; use serde_json::{Value, json}; @@ -38,25 +11,17 @@ const WEBSITE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const WEBSITE_USER_AGENT: &str = "siGit/0.1 (+https://github.com/getsigit/sigit; website-reading tool)"; -/// Maximum characters returned from `read_file` before truncation. const READ_FILE_CHAR_LIMIT: usize = 10_000; - -/// Maximum number of matching lines returned from `search_files`. const SEARCH_FILES_MATCH_LIMIT: usize = 50; // ── Tool schemas ───────────────────────────────────────────────────────────── -/// A tool definition with its JSON Schema and metadata for the LLM. pub struct AgentTool { - /// Machine-readable tool name (e.g. `"read_file"`). pub name: &'static str, - /// Human-readable description shown to the LLM. pub description: &'static str, - /// JSON Schema describing the tool's parameters. pub parameters_schema: Value, } -/// Return all available agent tools. pub fn all_tools() -> Vec<AgentTool> { vec![ AgentTool { @@ -271,19 +236,13 @@ pub fn all_tools() -> Vec<AgentTool> { // ── Tool execution ─────────────────────────────────────────────────────────── -/// Execute a tool by name with the given JSON arguments string. -/// -/// Returns the tool output as a human-readable string. Errors are returned as -/// descriptive strings rather than panicking. pub async fn execute_tool(name: &str, arguments: &str) -> String { match name { "read_file" => exec_read_file(arguments), "list_directory" => exec_list_directory(arguments), "search_files" => exec_search_files(arguments), "read_website" => { - // reqwest::blocking panics if called inside a tokio runtime - // ("Cannot start a runtime from within a runtime"), so we - // off-load it to the blocking thread pool. + // reqwest::blocking panics inside a tokio runtime, so run on the blocking pool. let args = arguments.to_owned(); tokio::task::spawn_blocking(move || exec_read_website(&args)) .await @@ -314,7 +273,6 @@ fn absolute_path_string(path: &Path) -> String { // ── read_file ──────────────────────────────────────────────────────────────── -/// Read the contents of a single file, truncating at [`READ_FILE_CHAR_LIMIT`]. fn exec_read_file(arguments: &str) -> String { let args: Value = match serde_json::from_str(arguments) { Ok(v) => v, @@ -380,7 +338,6 @@ fn exec_read_file(arguments: &str) -> String { // ── list_directory ─────────────────────────────────────────────────────────── -/// List directory entries, directories first, sorted alphabetically. fn exec_list_directory(arguments: &str) -> String { let args: Value = match serde_json::from_str(arguments) { Ok(v) => v, @@ -438,7 +395,6 @@ fn exec_list_directory(arguments: &str) -> String { dirs.sort(); files.sort(); - // Directories first, then files. dirs.extend(files); if dirs.is_empty() { @@ -450,7 +406,6 @@ fn exec_list_directory(arguments: &str) -> String { // ── search_files ───────────────────────────────────────────────────────────── -/// Recursively search files for a regex pattern, returning matching lines. fn exec_search_files(arguments: &str) -> String { let args: Value = match serde_json::from_str(arguments) { Ok(v) => v, @@ -499,13 +454,8 @@ fn exec_search_files(arguments: &str) -> String { matches.join("\n") } -/// Recursively walk a directory and collect regex matches. -/// -/// Skips hidden directories (names starting with `.`) and binary files. -/// Stops collecting once the match list reaches a generous internal cap (2× -/// the public limit) to avoid unbounded work. +/// caps collected matches at 2x the public limit to bound work on large trees. fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) { - // Internal cap to avoid scanning the entire filesystem. const WALK_CAP: usize = SEARCH_FILES_MATCH_LIMIT * 2; let entries = match fs::read_dir(dir) { @@ -513,7 +463,6 @@ fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) { Err(_) => return, }; - // Collect and sort for deterministic output. let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect(); sorted.sort_by_key(|e| e.file_name()); @@ -526,7 +475,6 @@ fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) { let name = entry.file_name(); let name_str = name.to_string_lossy(); - // Skip hidden entries. if name_str.starts_with('.') { continue; } @@ -539,13 +487,10 @@ fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) { } } -/// Search a single file line-by-line for the regex pattern. -/// -/// Skips files that cannot be read as UTF-8 (assumed binary). +/// skips non-UTF-8 files (probably binary). fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) { let contents = match fs::read_to_string(path) { Ok(c) => c, - // Skip binary / unreadable files silently. Err(_) => return, }; @@ -677,7 +622,6 @@ fn exec_read_website(arguments: &str) -> String { // ── create_directory ───────────────────────────────────────────────────────── -/// Create a directory and any missing parent directories. fn exec_create_directory(arguments: &str) -> String { let args: Value = match serde_json::from_str(arguments) { Ok(v) => v, @@ -706,11 +650,7 @@ fn exec_create_directory(arguments: &str) -> String { } } -/// Create a new file with the provided content. -/// -/// Parent directories are created automatically. Fails if the file already -/// exists to prevent accidental overwrites — the LLM should use `edit_file` -/// for existing files. +/// fails if file exists so the LLM is forced to use `edit_file` for modifications. fn exec_create_file(arguments: &str) -> String { let args: Value = match serde_json::from_str(arguments) { Ok(v) => v, @@ -737,7 +677,6 @@ fn exec_create_file(arguments: &str) -> String { ); } - // Create parent directories if needed. if let Some(parent) = absolute_path.parent() && !parent.as_os_str().is_empty() && !parent.exists() @@ -757,12 +696,7 @@ fn exec_create_file(arguments: &str) -> String { // ── edit_file ──────────────────────────────────────────────────────────────── -/// Edit an existing file by replacing an exact occurrence of `old_text` with -/// `new_text`. -/// -/// The `old_text` must appear **exactly once** in the file. This prevents -/// ambiguous edits and forces the LLM to read the file first to get the exact -/// text span. +/// `old_text` must match exactly once — ambiguity means the LLM didn't read the file first. fn exec_edit_file(arguments: &str) -> String { let args: Value = match serde_json::from_str(arguments) { Ok(v) => v, @@ -803,7 +737,6 @@ fn exec_edit_file(arguments: &str) -> String { Err(err) => return format!("Error: could not read file: {err}"), }; - // Count occurrences to give a clear error message. let occurrences = contents.matches(old_text).count(); if occurrences == 0 { @@ -833,10 +766,7 @@ fn exec_edit_file(arguments: &str) -> String { // ── delete_file ────────────────────────────────────────────────────────────── -/// Delete a file or empty directory at the given path. -/// -/// Refuses to remove non-empty directories to guard against accidental -/// recursive deletes. +/// only removes files or *empty* directories — no recursive deletes. fn exec_delete_file(arguments: &str) -> String { let args: Value = match serde_json::from_str(arguments) { Ok(v) => v, @@ -874,18 +804,10 @@ fn exec_delete_file(arguments: &str) -> String { // ── run_command ────────────────────────────────────────────────────────────── -/// Maximum time a command is allowed to run before being killed. const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); - -/// Maximum bytes of combined output returned from a command. const COMMAND_OUTPUT_LIMIT: usize = 50_000; -/// Run a shell command and return its combined stdout + stderr output. -/// -/// The command is executed via `sh -c` (Unix) or `cmd /C` (Windows) so shell -/// features like pipes, redirects, and chaining work out of the box. -/// -/// Long-running commands are killed after [`COMMAND_TIMEOUT`] seconds. +/// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT. fn exec_run_command(arguments: &str) -> String { let args: Value = match serde_json::from_str(arguments) { Ok(v) => v, @@ -937,7 +859,6 @@ fn exec_run_command(arguments: &str) -> String { Err(err) => return format!("Error: failed to spawn command: {err}"), }; - // Wait with a timeout. let start = std::time::Instant::now(); loop { match child.try_wait() { @@ -966,7 +887,6 @@ fn exec_run_command(arguments: &str) -> String { combined.push_str(&String::from_utf8_lossy(&output.stdout)); combined.push_str(&String::from_utf8_lossy(&output.stderr)); - // Truncate if output is huge. let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT { let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT]; format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")