From 278ebdd7a79ef097fe463522ce2707146d49326e Mon Sep 17 00:00:00 2001 From: Ferrol Aderholdt Date: Sun, 2 Aug 2026 23:39:40 -0700 Subject: [PATCH] Planning pane, menu bar, and the fixes from driving them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commits the planning pane and menu bar (both were uncommitted in the working tree) together with a round of fixes from actually using them. They are not separable by file: several fixes change the pane's own types. Menu bar Down was wired to "open the submenu", which selects item 0 unconditionally, so every press re-selected the same row and everything below the first item was unreachable by keyboard. Up now walks back and pops out to the section row from the first item. Model selection The orchestrator's Model row cycled [orchestrator].models, unset in most configs — a one-element list, so Enter set the model to what it already was. It opens a picker over the endpoint's /v1/models now. The planning picker already probed that endpoint but used the result for nothing but a count in the corner; Enter/-> descends into the model list, <- backs out, r re-probes. The configured model stays listed even when the server does not report it, so a model that fails to load cannot strand the picker. Shared probe in App::probe_models, skipping hosted catalogues and filtering embedding/reranker ids. Planning backends Every [agents.NAME] and an API-class [orchestrator] are now offered as planning backends under their own names, with an explicit [planning.backends.NAME] shadowing the derived one. A config with no [planning.backends.*] — every config predating the pane — previously offered nothing to select at all. Derived entries are serde(skip) so save() cannot write them back as though the user had authored them. Turn budget 40 tool round-trips instead of a hardcoded 10, per-backend and inherited from an API-class orchestrator's own setting. Ten hops is a couple of files, and grounding a plan in a codebase is a grep-read-grep-read walk. Hitting the ceiling no longer discards the turn: the final pass runs with the tools removed, so the reply comes from what was gathered. Context meter Reported ~0k for anything under 1000 tokens (integer division), and measured only the transcript — which by design carries no tool traffic, so reading half the repository barely moved it. Sub-1k counts keep their digits, and each reply carries back the peak request size: ~868/131k (peak 5.4k). Fullscreen alt-shift-p gives the pane the whole output region. The session bar and status panel stay, so the sessions being planned against remain visible and keep updating; no PTY is resized. Local-backend errors "api error: unknown" for any server sending error as a bare string rather than {"error": {"message": ...}} — the one class of backend whose failures most need debugging was the one reporting nothing. Verified in a tmux harness against LM Studio on localhost:1234: the model pickers list real models and apply the selection, fullscreen leaves the status panel live, and the context meter reads ~129/131k (peak 5.4k) after a turn that read two files. Co-Authored-By: Claude --- CHANGELOG.md | 11 + docs/panes-and-navigation.md | 93 ++ src/app.rs | 2216 +++++++++++++++++++++++++++++++++- src/config.rs | 268 +++- src/events.rs | 47 + src/keybindings.rs | 11 + src/main.rs | 121 +- src/planning/distill.rs | 298 +++++ src/planning/mod.rs | 848 +++++++++++++ src/planning/store.rs | 795 ++++++++++++ src/planning/tools.rs | 627 ++++++++++ src/ui.rs | 929 +++++++++++++- 12 files changed, 6164 insertions(+), 100 deletions(-) create mode 100644 src/planning/distill.rs create mode 100644 src/planning/mod.rs create mode 100644 src/planning/store.rs create mode 100644 src/planning/tools.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c3dc206..3eea377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Fixed the menu bar's Down arrow doing nothing past the first press: it was wired to "open the submenu", which selects item 0 unconditionally, so every press re-selected the same row and the items below the first were unreachable by keyboard. Up now walks back up and pops out to the section row from the first item. This is what made the planning backend picker (Agenda → Planning Model, the second item) look like it did not exist. +- Fixed the planning pane's context meter reporting `~0k` for any thread under 1000 tokens — `used / 1000` in integer arithmetic — which read as a meter that was not measuring anything rather than one reporting a small number. Counts below 1k now keep their digits. +- The context meter also now reports what a turn actually sent. Tool results are consumed inside a turn and never persisted (deliberately — it is what keeps a thread portable between a 32k local model and a 200k hosted one), so a turn that read half the repository left a transcript of a few hundred tokens and a meter that barely moved. Each reply carries the peak request size back, shown as `~868/131k (peak 5.4k)`. The first number is still what the *next* turn starts from; the peak belongs to the turn that produced it and is not restored when a thread is reopened. +- Planning turns get 40 tool round-trips instead of 10, and the ceiling is now a per-backend `max_tool_iterations` (an API-class orchestrator's own setting is inherited by its derived backend). Ten hops is a couple of files — not enough to ground a plan in a codebase, which is a grep-read-grep-read walk. Hitting the ceiling also no longer throws the turn away: the final pass runs with the tools removed, so you get an answer written from what was gathered rather than "[tool iteration limit reached — ask a narrower question]". +- A planning turn's failure message no longer collapses to "api error: unknown" against a local server. OpenAI and Anthropic send `{"error": {"message": ...}}`; LM Studio and llama.cpp often send a bare string, and only `["message"]` was read — so the one class of backend whose failures you most need to debug was the one that reported nothing. (What it was hiding here: `Failed to load model "..."`.) +- Added `alt-shift-p`: the planning pane takes the whole output region, docking itself first if it was closed. The session bar and status panel stay visible, so the sessions being planned against remain on screen and keep updating; only their output is covered, and no PTY is resized. `esc` steps back to the split. +- The planning backend picker (`alt-m`, or Agenda → Planning Model) now asks each endpoint what it is serving and lets you pick a model from the answer, instead of only choosing between endpoints on whatever model id the config happened to name. `/v1/models` was already being probed, but the result was used for nothing but a count in the corner of the picker. `Enter`/`→` on an endpoint descends into its model list, `←` backs out, `r` re-probes a server that has since loaded something else. Hosted catalogues are still not probed — they list hundreds of models and are not the thing that changes under you. +- The planning pane no longer requires a second copy of an endpoint you have already configured. Every `[agents.NAME]` and an API-class `[orchestrator]` are offered as planning backends under their own names; an explicit `[planning.backends.NAME]` shadows the derived one. Previously a config with no `[planning.backends.*]` — which is every config that predates the pane — offered nothing to select and cycling the picker failed silently. + +- Added the planning pane (`alt-p`): a persistent, read-only, single-agent design chat that docks into a split leaf beside a session. Where the orchestrator chat is an append-only log, a planning thread is a document — it lives on disk, survives a restart, is scrolled back through, and distills into a plan revision handed to an implementation session. The pane shows a thread list, the scope root the thread is grounded in with a count of files that changed since it read them, per-message model attribution with a rule marking where a thread switched models, a context-budget meter, and a backend picker (`alt-m`). Threads over the selected backend's window prompt to compact or switch rather than compacting silently, which would drop the early turns a plan rests on. `alt-i` hands a committed plan to a session as work — a file path plus a freshly recomputed staleness warning, queued if the target is mid-turn. + - Fixed two OpenCode sessions in the same directory reporting identical tokens, context and state: both watchers bound to the newest db session in that directory. Each watcher now claims a distinct one, and releases it when its session goes away. - Fixed OpenCode sessions getting stuck on WAITING indefinitely. A permission dialog is only visible to terminal pattern matching, so it has to outrank the db watcher's Running/Thinking — but it was never given up, and the watcher only re-reported on a *change*. WAITING now releases when the dialog leaves the screen, when input is sent to the session, or when the watcher reports the turn finished (now re-asserted every 5s rather than 30s). - Fixed a freshly started full-screen TUI session (OpenCode) sitting on RUNNING forever: the idle-timeout check keyed off the last completed output line, which a TUI that repaints via cursor positioning never produces. diff --git a/docs/panes-and-navigation.md b/docs/panes-and-navigation.md index 79f3833..dd25d8d 100644 --- a/docs/panes-and-navigation.md +++ b/docs/panes-and-navigation.md @@ -61,6 +61,9 @@ and shows real token counts while skipping meaningless cost. | `alt-o` | Focus next pane | | `alt-b` | Toggle broadcast input to all sessions | | `alt-g` | Dock the chat pane | +| `alt-p` | Toggle the planning pane | +| `alt-shift-p` | Planning pane over the whole output region (docks it first if closed) | +| `ctrl-space` | Toggle the menu bar | | `alt-1` … `alt-8` | Switch to session by number | | `alt-←` / `alt-→` | Cycle sessions | | `ctrl-q` | Quit (shuts down the server and all sessions; use `alt-d` to leave them running) | @@ -71,6 +74,96 @@ and shows real token counts while skipping meaningless cost. All other input is passed through to the active session's PTY. Keybindings are configurable — see the [configuration reference](config-reference.md). +### Menu bar keys + +`ctrl-space` opens a menu bar across the top (Sessions, View, Pipes, +Orchestrator, Agenda, Help). It is the runtime settings surface: the +Orchestrator section cycles persona, provider, model, context budget, approval +mode and the wake-on-event toggles, and the Agenda section opens the planning +backend/model picker, all without editing config.toml. + +| Key | Action | +|-----|--------| +| `←` / `→` | Move between sections | +| `↓` | Drop into the section, then move down its items | +| `↑` | Move up; from the first item, back out to the section row | +| `Enter` | Activate the item. Value rows (model, context, toggles) cycle in place and leave the menu open | +| a letter | Jump to the section whose title starts with it | +| `ctrl-space` / `esc` | Close | + +Rows whose action is unavailable are greyed out and do nothing rather than +closing the menu — the orchestrator lifecycle rows are greyed while it is not +running, and "Planning Model" is greyed when there is no endpoint to pick from. + +The planning backend list does not have to be written out twice: every +`[agents.NAME]` endpoint, and an API-class `[orchestrator]`, are offered as +planning backends automatically under their own names. A +`[planning.backends.NAME]` entry with the same name shadows the derived one, +so declaring a backend explicitly is how you give it a different model or +context budget than the agent it came from. Derived backends are never written +back into config.toml. + +### Planning pane keys + +`alt-p` docks the planning pane into a split leaf; `alt-shift-p` gives it the +whole output region, docking it first if it was closed. A plan is a document, +and a third of a split is not enough room to hold one in your head. The session +bar and status panel stay visible, so the sessions you are planning against are +still on screen with their state and token counts updating — only their output +is covered, and their PTYs keep the size they last laid out at. `esc` steps +back to the split, and a second `esc` moves focus on. + +While the pane is focused these keys apply instead of being passed to a PTY: + +| Key | Action | +|-----|--------| +| `Enter` | Send the input as a planning turn | +| `alt-Enter` | Insert a newline (a planning message is usually a paragraph) | +| `Tab` | Move focus between the thread list and the transcript | +| `ctrl-b` | Collapse/expand the thread list | +| `alt-m` | Open the backend/model picker | +| `ctrl-k` | Commit the thread to a plan revision | +| `alt-i` | Hand the committed plan to a session as work | +| `↑` / `↓` | Thread list: change selection. Transcript: scroll | +| `PageUp` / `PageDown` | Scroll the transcript | +| `Enter` (list focus) | Open the selected thread | +| `n` (list focus) | New thread — browse for its scope root | +| `d` (list focus) | Delete the selected thread (confirms first) | +| `esc` | Leave fullscreen, else return focus to the other pane | + +The backend picker is `alt-m` rather than `ctrl-m` because terminals encode +`ctrl-m` as carriage return, making it indistinguishable from `Enter`. + +It has two levels. The first lists backends — endpoints. On opening, every +self-hosted endpoint is asked what it is currently serving (`GET /v1/models`, +2s timeout, cached), and `Enter` or `→` on one descends into that answer to +pick a model; `←` backs out, `r` re-probes. Hosted catalogues +(`api.openai.com`, Anthropic) are not asked — they list hundreds of models and +are not what changes under you — so `Enter` there selects the endpoint on its +configured model, as does an endpoint that did not respond. A model picked +this way applies to the next turn and is recorded on each message, so a thread +shows where it switched models. + +`alt-i` hands the thread's latest committed plan to a session you pick. What +is sent is a path plus a staleness warning, not the conversation: an +implementation session may run sandboxed, and a read-only bind mount of one +file is simpler to arrange than replaying a thread. Staleness is recomputed at +handoff, not reused from commit time, so a plan grounded in files that have +since moved on says so. The brief queues if the target session is mid-turn. + +The status row's context meter shows two numbers: `~868/131k (peak 5.4k)`. The +first is the thread transcript plus your draft — what the *next* turn starts +from. The peak is the largest request the last turn actually built, including +the file contents it read. They diverge sharply, because tool results are +consumed within a turn and never stored in the thread, so reading a codebase +moves the peak and barely touches the transcript. The peak is not restored when +a thread is reopened; it describes a turn, not a thread. + +When a thread exceeds the selected backend's context budget the pane shows a +prompt offering `[c]` compact, `[b]` switch backend, or `[Esc]` dismiss. This +is deliberately not automatic: compacting silently would drop the early turns, +which in a planning thread are usually the premises everything else rests on. + ## Command bar Press `alt-c` to open. Available commands: diff --git a/src/app.rs b/src/app.rs index b1c12a2..15a7f53 100644 --- a/src/app.rs +++ b/src/app.rs @@ -174,6 +174,13 @@ pub enum AppMode { selected_top: usize, selected_sub: Option, }, + /// Picking the orchestrator's model from what its endpoint reports. A + /// mode rather than a menu row because the list is live and can run to + /// dozens of entries — cycling through those one Enter at a time is not + /// choosing, it is scrolling with extra steps. + OrchestratorModel { + selected: usize, + }, Search { query: String, cursor: usize, @@ -253,6 +260,89 @@ pub struct PendingKill { pub requested_at: std::time::Instant, } +/// State of the planning pane. +/// +/// The pane is a two-column view: a collapsible thread list on the left, the +/// open thread's transcript and input on the right. Unlike `ChatState`, whose +/// transcript is ephemeral and tail-oriented, everything here is backed by a +/// file on disk and survives a restart. +#[derive(Default)] +pub struct PlanningState { + /// Open thread, if any. `None` means the pane shows only the list. + pub thread: Option, + /// Sidebar rows, newest first. + pub threads: Vec, + pub list_selected: usize, + pub sidebar_collapsed: bool, + pub input: String, + pub cursor: usize, + /// Lines scrolled up from the tail of the transcript. + pub scroll: usize, + /// Backend the next turn will use. Switching mid-thread is expected; the + /// per-message record in the thread is what preserves the seam. + pub backend: Option, + /// True while a turn or commit is in flight; the input is not sent twice. + pub busy: bool, + /// Latest progress line from the running turn. + pub status: String, + /// Last error, kept visible until the next successful turn. + pub error: String, + /// Set when the last failure was a context overflow, so the pane can + /// offer compact/fork instead of a plain retry. + pub overflow: bool, + /// Backend picker open, with the candidate index. + pub picker: Option, + /// Second level of the picker: the model list probed from the highlighted + /// backend's endpoint, with the candidate index. A backend is an endpoint, + /// and an endpoint usually serves several models — picking the endpoint + /// without picking the model only gets you halfway. + pub picker_model: Option, + /// Which column keys drive: the thread list or the transcript/input. + pub focus: PlanningFocus, + /// Thread id awaiting a delete confirmation. + pub confirm_delete: Option, + /// Handoff picker open, with the candidate index into the eligible + /// session list. Opening a committed plan as work is the payoff of the + /// whole pane, so it gets an explicit target rather than guessing one. + pub handoff: Option, + /// Estimated tokens in the largest request the last turn built. The + /// thread's own messages carry no tool traffic, so without this the meter + /// reports a few hundred tokens for a turn that read half the repository. + pub last_peak_tokens: usize, + /// Model ids probed from a local backend's `/v1/models`, keyed by backend + /// name. Cached because the picker would otherwise hit the endpoint on + /// every open, and a local server's list changes rarely. + pub model_cache: std::collections::HashMap>, +} + +/// Which column of the planning pane has key focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PlanningFocus { + /// Typing goes to the input; Up/Down scroll the transcript. + #[default] + Transcript, + /// Up/Down move the thread selection; Enter opens it. + Sidebar, +} + +impl PlanningState { + /// Reload the sidebar from disk. + pub fn refresh_list(&mut self) { + self.threads = crate::planning::store::list().unwrap_or_default(); + if self.list_selected >= self.threads.len() { + self.list_selected = self.threads.len().saturating_sub(1); + } + } + + /// Label for the status bar: which model the next turn goes to. + pub fn backend_label(&self) -> String { + self.backend + .as_ref() + .map(|b| b.label()) + .unwrap_or_else(|| "no backend configured".to_string()) + } +} + #[derive(Debug, Clone, Default)] pub struct ChatState { pub input: String, @@ -364,6 +454,18 @@ const COMMAND_PALETTE: &[(&str, &str, &str)] = &[ ("quit", "Exit linkshell", "quit"), ]; +/// What the file browser is picking a directory *for*. The browser is shared, +/// so it has to know where to hand the result back. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FileBrowserPurpose { + /// Fill the new-session dialog's cwd field. + #[default] + NewSessionCwd, + /// Pick the scope root for a new planning thread. The root is pinned + /// permanently at creation, so it is worth making easy to get right. + PlanningRoot, +} + #[derive(Debug, Clone)] pub struct FileBrowserState { pub current_dir: PathBuf, @@ -452,6 +554,7 @@ pub struct App { pub browse_button_area: Rect, pub file_browser_area: Rect, pub file_browser_state: FileBrowserState, + pub file_browser_purpose: FileBrowserPurpose, pub command_bar_area: Rect, pub help_area: Rect, pub chat_area: Rect, @@ -517,9 +620,26 @@ pub struct App { /// `/yes` and `/no` without a target answer this one. pub last_permission_request: Option, pub chat: ChatState, + pub planning: PlanningState, + /// Models the orchestrator's endpoint reports serving. Empty until + /// probed; falls back to `[orchestrator].models` for a provider whose + /// catalogue is not worth listing. + pub orchestrator_models: Vec, + /// Orchestrator settings were changed from the menu while it was + /// running; the running task holds a snapshot, so a restart is needed. + pub orchestrator_config_dirty: bool, /// When Some(p), split pane `p` renders the chat instead of a session /// and keyboard input goes to the chat while that pane is focused. pub chat_docked: Option, + /// When Some(p), split pane `p` renders the planning thread instead of a + /// session and keyboard input goes to the planning pane while focused. + pub planning_docked: Option, + /// The planning pane takes the whole output region, replacing the session + /// panes. A plan is a document you read and scroll, and a third of a split + /// is not enough room to hold one in your head. The session bar and status + /// panel stay put, so the sessions being planned against are still visible + /// and still updating — only their output is covered. + pub planning_fullscreen: bool, /// When true, key input is forwarded to all non-dead sessions pub broadcast_mode: bool, pub settings_state: SettingsState, @@ -579,6 +699,7 @@ impl App { browse_button_area: Rect::default(), file_browser_area: Rect::default(), file_browser_state: FileBrowserState::new("."), + file_browser_purpose: FileBrowserPurpose::default(), command_bar_area: Rect::default(), help_area: Rect::default(), chat_area: Rect::default(), @@ -586,6 +707,7 @@ impl App { chat_scroll_max: 0, chat_visible_lines: Vec::new(), chat_selection: None, + orchestrator_models: Vec::new(), menu_bar_area: Rect::default(), menu_item_areas: Vec::new(), menu_submenu_area: Rect::default(), @@ -614,7 +736,11 @@ impl App { orch_event_cooldowns: HashMap::new(), last_permission_request: None, chat: ChatState::default(), + planning: PlanningState::default(), + orchestrator_config_dirty: false, chat_docked: None, + planning_docked: None, + planning_fullscreen: false, broadcast_mode: false, settings_state: SettingsState::new_empty(), clipboard: None, @@ -1060,6 +1186,9 @@ impl App { if self.chat_docked == Some(self.focused_pane) { self.chat_docked = None; } + if self.planning_docked == Some(self.focused_pane) { + self.planning_docked = None; + } // Don't show the same session in two panes at once. let shown_elsewhere = self .panes @@ -1138,6 +1267,11 @@ impl App { *dock += 1; } } + if let Some(dock) = self.planning_docked.as_mut() { + if *dock >= new_slot { + *dock += 1; + } + } self.focused_pane = new_slot; // No immediate PTY resize: the true pane geometry isn't known until the // next draw, whose post-draw handle_pane_resize sizes the new pane's @@ -1165,6 +1299,13 @@ impl App { self.chat_docked = Some(dock - 1); } } + if let Some(dock) = self.planning_docked { + if dock == removed { + self.planning_docked = None; + } else if dock > removed { + self.planning_docked = Some(dock - 1); + } + } self.focused_pane = removed.min(self.panes.len() - 1); self.needs_redraw = true; } @@ -1181,6 +1322,11 @@ impl App { pub fn focus_next_pane(&mut self) { if self.is_split() { self.focused_pane = (self.focused_pane + 1) % self.panes.len(); + // Focus that leaves the fullscreen planning pane has to restore + // the split, or keys go to a pane covered by another one. + if self.planning_fullscreen && self.planning_docked != Some(self.focused_pane) { + self.planning_fullscreen = false; + } self.needs_redraw = true; } } @@ -2907,7 +3053,7 @@ impl App { self.mode = AppMode::Normal; return; } - AppMode::Normal | AppMode::Menu { .. } => {} + AppMode::Normal | AppMode::Menu { .. } | AppMode::OrchestratorModel { .. } => {} } // Docked chat pane click → focus it, maybe start a transcript @@ -3170,7 +3316,7 @@ impl App { { for (idx, area) in self.menu_submenu_item_areas.iter().enumerate() { if rect_hit(*area, col, row) { - self.execute_menu_action(selected_top, idx); + self.activate_menu_index(selected_top, idx); return; } } @@ -3406,6 +3552,17 @@ impl App { self.new_session_state.cwd.clone() }; self.file_browser_state = FileBrowserState::new(&start); + self.file_browser_purpose = FileBrowserPurpose::NewSessionCwd; + self.mode = AppMode::FileBrowser; + } + + /// Open the browser to pick a scope root for a new planning thread. + pub fn open_planning_root_browser(&mut self) { + let start = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()); + self.file_browser_state = FileBrowserState::new(&start); + self.file_browser_purpose = FileBrowserPurpose::PlanningRoot; self.mode = AppMode::FileBrowser; } @@ -3450,14 +3607,46 @@ impl App { .current_dir .to_string_lossy() .to_string(); - self.new_session_state.cwd = s.clone(); - self.new_session_state.cwd_cursor = s.len(); - self.new_session_state.active_field = NewSessionField::Cwd; - self.mode = AppMode::NewSession; + match self.file_browser_purpose { + FileBrowserPurpose::NewSessionCwd => { + self.new_session_state.cwd = s.clone(); + self.new_session_state.cwd_cursor = s.len(); + self.new_session_state.active_field = NewSessionField::Cwd; + self.mode = AppMode::NewSession; + } + FileBrowserPurpose::PlanningRoot => { + // Title the thread after the directory; it is renameable by + // editing the markdown, and a name beats "untitled" in the list. + let title = self + .file_browser_state + .current_dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + if let Err(e) = self.planning_new_thread(&title, &s) { + self.planning.error = e; + } + self.file_browser_purpose = FileBrowserPurpose::default(); + self.mode = AppMode::Normal; + if let Some(pane) = self.planning_docked { + self.focused_pane = pane; + } + self.planning.focus = PlanningFocus::Transcript; + } + } } pub fn file_browser_cancel(&mut self) { - self.mode = AppMode::NewSession; + match self.file_browser_purpose { + FileBrowserPurpose::NewSessionCwd => self.mode = AppMode::NewSession, + FileBrowserPurpose::PlanningRoot => { + self.file_browser_purpose = FileBrowserPurpose::default(); + self.mode = AppMode::Normal; + if let Some(pane) = self.planning_docked { + self.focused_pane = pane; + } + } + } } pub fn handle_file_browser_mouse(&mut self, col: u16, row: u16, list_visible: usize) { @@ -4377,10 +4566,17 @@ impl App { selected_sub, } = self.mode { - let next = ((selected_top as i32 + delta).rem_euclid(MENU.len() as i32)) as usize; + let sections = self.menu(); + if sections.is_empty() { + return; + } + let next = ((selected_top as i32 + delta).rem_euclid(sections.len() as i32)) as usize; + // Submenu indices are per-section, so clamp rather than carry a + // stale index into a shorter menu. + let sub = selected_sub.map(|i| i.min(sections[next].items.len().saturating_sub(1))); self.mode = AppMode::Menu { selected_top: next, - selected_sub, + selected_sub: sub, }; } } @@ -4391,11 +4587,28 @@ impl App { selected_sub, } = self.mode { - let count = MENU[selected_top].1.len() as i32; + let sections = self.menu(); + let Some(section) = sections.get(selected_top) else { + return; + }; + let count = section.items.len() as i32; + if count == 0 { + return; + } let cur = selected_sub.unwrap_or(0) as i32; + // Separators are not landable; step past them in the direction of + // travel so arrow keys never park on a divider. + let step = if delta >= 0 { 1 } else { -1 }; + let mut next = (cur + delta).rem_euclid(count); + for _ in 0..count { + if section.items[next as usize].action != MenuAction::Separator { + break; + } + next = (next + step).rem_euclid(count); + } self.mode = AppMode::Menu { selected_top, - selected_sub: Some(((cur + delta).rem_euclid(count)) as usize), + selected_sub: Some(next as usize), }; } } @@ -4406,6 +4619,8 @@ impl App { selected_top, selected_sub: Some(0), }; + // Land on the first selectable row. + self.menu_move_sub(0); } } @@ -4418,27 +4633,109 @@ impl App { } } + /// Jump to the section whose title starts with `c` (menu mnemonics). + /// Derived from the live titles rather than hardcoded, so adding a + /// section cannot silently steal another one's letter. + pub fn menu_jump_to_mnemonic(&mut self, c: char) -> bool { + let want = c.to_ascii_lowercase(); + let idx = self.menu().iter().position(|s| { + s.title + .chars() + .next() + .map(|t| t.to_ascii_lowercase() == want) + .unwrap_or(false) + }); + match idx { + Some(i) => { + self.mode = AppMode::Menu { + selected_top: i, + selected_sub: Some(0), + }; + self.menu_move_sub(0); + true + } + None => false, + } + } + pub fn execute_selected_menu_action(&mut self) { if let AppMode::Menu { selected_top, selected_sub, } = self.mode { - self.execute_menu_action(selected_top, selected_sub.unwrap_or(0)); + self.activate_menu_index(selected_top, selected_sub.unwrap_or(0)); } } - fn execute_menu_action(&mut self, top: usize, sub: usize) { - self.mode = AppMode::Normal; - match (top, sub) { - (0, 0) => self.open_new_session(), - (0, 1) => self.kill_active_session(), - (0, 2) => self.next_session(), - (0, 3) => self.prev_session(), - (1, 0) => self.scroll_up(20), - (1, 1) => self.scroll_down(20), - (1, 2) => self.clear_scroll(), - (2, 0) => { + /// Activate the item at a (section, item) position — used by both the + /// keyboard and mouse paths. Resolves the position against the *current* + /// menu, so a stale index from a previous frame is ignored rather than + /// firing the wrong action. + pub fn activate_menu_index(&mut self, top: usize, sub: usize) { + let action = match self.menu().get(top).and_then(|s| s.items.get(sub)) { + Some(item) if item.enabled => item.action.clone(), + // A disabled row is a no-op that leaves the menu open, so the + // user can see *why* it is greyed out rather than being dropped + // back to the session with nothing having happened. + Some(_) => return, + None => return, + }; + self.execute_menu_action(action, top, sub); + } + + fn execute_menu_action(&mut self, action: MenuAction, top: usize, sub: usize) { + let outcome = self.run_menu_action(action); + match outcome { + MenuOutcome::Close => { + if matches!(self.mode, AppMode::Menu { .. }) { + self.mode = AppMode::Normal; + } + } + // Cycling items (model, context, approval) keep the menu open so + // a value can be stepped through without reopening four times. + MenuOutcome::Stay => { + self.mode = AppMode::Menu { + selected_top: top, + selected_sub: Some(sub), + }; + } + } + self.needs_redraw = true; + } + + fn run_menu_action(&mut self, action: MenuAction) -> MenuOutcome { + match action { + MenuAction::Separator => MenuOutcome::Stay, + MenuAction::NewSession => { + self.open_new_session(); + MenuOutcome::Close + } + MenuAction::KillSession => { + self.kill_active_session(); + MenuOutcome::Close + } + MenuAction::NextSession => { + self.next_session(); + MenuOutcome::Close + } + MenuAction::PrevSession => { + self.prev_session(); + MenuOutcome::Close + } + MenuAction::ScrollUp => { + self.scroll_up(20); + MenuOutcome::Close + } + MenuAction::ScrollDown => { + self.scroll_down(20); + MenuOutcome::Close + } + MenuAction::ClearScroll => { + self.clear_scroll(); + MenuOutcome::Close + } + MenuAction::ListPipes => { self.command_result = if self.pipes.is_empty() { "No active pipes".into() } else { @@ -4461,29 +4758,511 @@ impl App { .join(" | ") }; self.mode = AppMode::CommandResult; + MenuOutcome::Close } - (2, 1) => { - self.command_input = "pipe ".into(); + MenuAction::PrefillCommand(text) => { + self.command_input = text; self.command_cursor = self.command_input.len(); self.mode = AppMode::CommandBar; + MenuOutcome::Close } - (2, 2) => { - self.command_input = "unpipe ".into(); - self.command_cursor = self.command_input.len(); - self.mode = AppMode::CommandBar; + MenuAction::Help => { + self.mode = AppMode::Help; + MenuOutcome::Close } - (3, 0) | (3, 1) => self.mode = AppMode::Help, - _ => {} + + // ── Orchestrator lifecycle ──────────────────────────────────── + MenuAction::OrchestratorStart => { + self.command_result = match self.start_orchestrator() { + Ok(()) => { + self.orchestrator_config_dirty = false; + "orchestrator started".to_string() + } + Err(e) => format!("orchestrator: {}", e), + }; + MenuOutcome::Stay + } + MenuAction::OrchestratorStop => { + self.stop_orchestrator(); + MenuOutcome::Stay + } + MenuAction::OrchestratorRestart => { + self.stop_orchestrator(); + self.command_result = match self.start_orchestrator() { + Ok(()) => { + self.orchestrator_config_dirty = false; + "orchestrator restarted".to_string() + } + Err(e) => format!("orchestrator: {}", e), + }; + MenuOutcome::Stay + } + MenuAction::OrchestratorTogglePause => { + let pause = !self.orchestrator_paused; + self.set_orchestrator_paused(pause); + MenuOutcome::Stay + } + MenuAction::OrchestratorToggleHidden => { + if let Some(orch_id) = self.orchestrator_session_id { + let now_hidden = self + .sessions + .iter() + .find(|s| s.id == orch_id) + .map(|s| s.hidden) + .unwrap_or(false); + let show = now_hidden; + if let Some(s) = self.sessions.iter_mut().find(|s| s.id == orch_id) { + s.hidden = !show; + } + if !show { + let idx = self.sessions.iter().position(|s| s.id == orch_id); + for pane in &mut self.panes { + if *pane == idx { + *pane = None; + } + } + if self.panes[0].is_none() { + self.panes[0] = self.visible_indices().first().copied(); + } + } + } + MenuOutcome::Stay + } + + // ── Orchestrator settings ───────────────────────────────────── + // + // These edit the in-memory config only. Writing config.toml would + // mean reserializing a file the user hand-wrote with comments, so + // runtime changes stay for the session and the file remains the + // durable source of truth. + MenuAction::CyclePersona(delta) => { + let names: Vec = self.personas().into_iter().map(|p| p.name).collect(); + if names.is_empty() { + return MenuOutcome::Stay; + } + let next = cycle_value(&names, &self.orchestrator_persona, delta); + // Personas hot-swap: they layer over the base config and take + // effect on the next turn without discarding history, so this + // one deliberately does not mark the config dirty. + self.set_persona(&next); + MenuOutcome::Stay + } + MenuAction::CycleProvider(delta) => { + let choices = self.config.orchestrator.provider_choices(); + let current = self.config.orchestrator.provider.clone(); + let next = cycle_value(&choices, ¤t, delta); + self.config_mut().orchestrator.provider = next.clone(); + self.mark_orchestrator_dirty(); + self.command_result = format!("orchestrator provider → {}", next); + MenuOutcome::Stay + } + MenuAction::PickModel => { + self.open_orchestrator_model_picker(); + MenuOutcome::Close + } + MenuAction::CycleContext(delta) => { + let choices = self.config.orchestrator.context_choices(); + let current = self.config.orchestrator.max_context_tokens; + let idx = choices.iter().position(|c| *c == current).unwrap_or(0) as i32; + let next = choices[((idx + delta).rem_euclid(choices.len() as i32)) as usize]; + self.config_mut().orchestrator.max_context_tokens = next; + self.mark_orchestrator_dirty(); + self.command_result = format!("orchestrator context budget → {}", next); + MenuOutcome::Stay + } + MenuAction::ToggleApproval => { + let propose = self.config.orchestrator.approval == "propose"; + let next = if propose { "auto" } else { "propose" }; + self.config_mut().orchestrator.approval = next.to_string(); + self.mark_orchestrator_dirty(); + self.command_result = format!("orchestrator approval → {}", next); + MenuOutcome::Stay + } + MenuAction::ToggleEvent(name) => { + let events = &mut self.config_mut().orchestrator.events; + match events.iter().position(|e| *e == name) { + Some(i) => { + events.remove(i); + } + None => events.push(name.clone()), + } + self.mark_orchestrator_dirty(); + MenuOutcome::Stay + } + MenuAction::ReloadConfig => { + self.config = std::sync::Arc::new(crate::config::load()); + self.orchestrator_config_dirty = false; + self.command_result = "config reloaded from disk".into(); + MenuOutcome::Stay + } + + // ── Planning ────────────────────────────────────────────────── + MenuAction::PlanningNew => { + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()); + self.command_result = match self.planning_new_thread("untitled", &cwd) { + Ok(()) => format!("planning thread opened in {}", cwd), + Err(e) => format!("planning: {}", e), + }; + MenuOutcome::Close + } + MenuAction::PlanningOpenPicker => { + // The picker draws inside the planning pane, so the pane has + // to exist before it can be opened from the menu. + if self.planning_docked.is_none() { + self.dock_planning(None); + } + self.planning_open_picker(); + MenuOutcome::Close + } + MenuAction::PlanningCommit => { + self.planning_commit(); + MenuOutcome::Close + } + } + } + + /// Tear down whichever orchestrator flavor is running. + fn stop_orchestrator(&mut self) { + self.orchestrator_paused = false; + if self.orchestrator.take().is_some() { + // Dropping the handle closes the channel; the task exits. + self.command_result = "orchestrator stopped".into(); + } else if let Some(orch_id) = self.orchestrator_session_id.take() { + if let Some(idx) = self.sessions.iter().position(|s| s.id == orch_id) { + self.remove_session(idx); + } + self.command_result = "orchestrator session killed".into(); + } else { + self.command_result = "orchestrator is not running".into(); + } + } + + /// True when the orchestrator is live in either flavor. + pub fn orchestrator_running(&self) -> bool { + self.orchestrator.is_some() || self.orchestrator_session_id.is_some() + } + + /// Settings edits are snapshotted into the agent task at spawn time, so a + /// running orchestrator does not pick them up. Rather than silently + /// restarting — which would throw away the conversation the user is in + /// the middle of — the menu flags that a restart is needed and leaves the + /// decision to them. + fn mark_orchestrator_dirty(&mut self) { + if self.orchestrator_running() { + self.orchestrator_config_dirty = true; + } + } + + /// Mutable access to the live config. Copy-on-write: other holders of the + /// `Arc` (background tasks with their own snapshot) keep the version they + /// started with, which is exactly why a restart is needed to apply. + pub fn config_mut(&mut self) -> &mut Config { + std::sync::Arc::make_mut(&mut self.config) + } + + /// Build the menu against current state. Rebuilt on every navigation and + /// draw rather than cached, so labels like "Stop Orchestrator" and the + /// current model always reflect reality. + pub fn menu(&self) -> Vec { + let orch = &self.config.orchestrator; + let running = self.orchestrator_running(); + let api_class = matches!(orch.class(), Ok(crate::config::OrchestratorClass::Api(_))); + + let mut orchestrator_items = vec![ + if running { + MenuItem::new("Stop", MenuAction::OrchestratorStop) + } else { + MenuItem::new("Start", MenuAction::OrchestratorStart) + }, + MenuItem::new("Restart", MenuAction::OrchestratorRestart).enabled_if(running), + MenuItem::new( + if self.orchestrator_paused { + "Resume" + } else { + "Pause" + }, + MenuAction::OrchestratorTogglePause, + ) + .enabled_if(running), + MenuItem::new("Show/Hide Session", MenuAction::OrchestratorToggleHidden) + .enabled_if(self.orchestrator_session_id.is_some()), + MenuItem::separator(), + MenuItem::new("Persona", MenuAction::CyclePersona(1)).with_detail( + if self.orchestrator_persona.is_empty() { + "(none)" + } else { + &self.orchestrator_persona + }, + ), + MenuItem::new("Provider", MenuAction::CycleProvider(1)).with_detail(&orch.provider), + MenuItem::new("Model", MenuAction::PickModel) + .with_detail(if orch.model.is_empty() { + "(default)" + } else { + &orch.model + }) + .enabled_if(api_class), + MenuItem::new("Context Budget", MenuAction::CycleContext(1)).with_detail(&if orch + .max_context_tokens + == 0 + { + "unlimited".to_string() + } else { + format_tokens(orch.max_context_tokens) + }), + MenuItem::new("Approval", MenuAction::ToggleApproval).with_detail(&orch.approval), + MenuItem::separator(), + ]; + // Wake-on-event toggles: each is a full orchestrator turn, which is + // expensive on a local model, so they are worth flipping per session. + for event in ["ready", "waiting", "error", "dead"] { + let on = orch.events.iter().any(|e| e == event); + orchestrator_items.push( + MenuItem::new( + &format!("Wake on {}", event), + MenuAction::ToggleEvent(event.to_string()), + ) + .with_detail(if on { "on" } else { "off" }), + ); } + orchestrator_items.push(MenuItem::separator()); + orchestrator_items.push(MenuItem::new( + "Reload Config From Disk", + MenuAction::ReloadConfig, + )); + if self.orchestrator_config_dirty { + orchestrator_items.push( + MenuItem::new("Restart To Apply Changes", MenuAction::OrchestratorRestart) + .with_detail("pending"), + ); + } + + let planning_backend = self + .planning + .backend + .as_ref() + .map(|b| b.label()) + .unwrap_or_else(|| "(none configured)".to_string()); + + vec![ + MenuSection { + title: "Sessions".into(), + items: vec![ + MenuItem::new("New Session", MenuAction::NewSession), + MenuItem::new("Kill Session", MenuAction::KillSession), + MenuItem::new("Next", MenuAction::NextSession), + MenuItem::new("Prev", MenuAction::PrevSession), + ], + }, + MenuSection { + title: "View".into(), + items: vec![ + MenuItem::new("Scroll Up", MenuAction::ScrollUp), + MenuItem::new("Scroll Down", MenuAction::ScrollDown), + MenuItem::new("Clear Scroll", MenuAction::ClearScroll), + ], + }, + MenuSection { + title: "Pipes".into(), + items: vec![ + MenuItem::new("List Pipes", MenuAction::ListPipes), + MenuItem::new("Add Pipe", MenuAction::PrefillCommand("pipe ".into())), + MenuItem::new("Remove Pipe", MenuAction::PrefillCommand("unpipe ".into())), + ], + }, + MenuSection { + title: "Orchestrator".into(), + items: orchestrator_items, + }, + MenuSection { + title: "Agenda".into(), + items: vec![ + MenuItem::new("New Planning Thread", MenuAction::PlanningNew), + MenuItem::new("Planning Model", MenuAction::PlanningOpenPicker) + .with_detail(&planning_backend) + // Nothing to cycle through until linkshell.toml + // declares at least one [planning.backends.NAME]. + .enabled_if(!self.config.planning.backend_names().is_empty()), + MenuItem::new("Commit Plan", MenuAction::PlanningCommit) + .enabled_if(self.planning.thread.is_some()), + ], + }, + MenuSection { + title: "Help".into(), + items: vec![ + MenuItem::new("Keybindings", MenuAction::Help), + MenuItem::new("About", MenuAction::Help), + ], + }, + ] } } -pub const MENU: &[(&str, &[&str])] = &[ - ("Sessions", &["New Session", "Kill Session", "Next", "Prev"]), - ("View", &["Scroll Up", "Scroll Down", "Clear Scroll"]), - ("Pipes", &["List Pipes", "Add Pipe", "Remove Pipe"]), - ("Help", &["Keybindings", "About"]), -]; +/// Ask an OpenAI-compatible endpoint what models it is serving. +/// +/// Shared by the planning backend picker and the orchestrator's model picker: +/// both face the same problem, which is that a self-hosted server's model list +/// is live state and a model id written into config goes stale the moment the +/// server loads something else. Failures come back as an empty list — an +/// unreachable endpoint is a picker with nothing in it, not an error worth +/// interrupting the user over. +pub async fn probe_models(url: &str, api_key: Option<&str>) -> Vec { + let Ok(client) = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + else { + return Vec::new(); + }; + let mut req = client.get(url); + if let Some(key) = api_key { + req = req.bearer_auth(key); + } + let Ok(resp) = req.send().await else { + return Vec::new(); + }; + let Ok(v) = resp.json::().await else { + return Vec::new(); + }; + v["data"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|m| m["id"].as_str().map(|s| s.to_string())) + // A server serves embedding and reranker models alongside + // chat ones; they cannot answer a turn, so listing them is + // only a way to pick wrong. + .filter(|id| { + let id = id.to_ascii_lowercase(); + !id.contains("embed") && !id.contains("rerank") + }) + .collect() + }) + .unwrap_or_default() +} + +/// Step through a list of choices, landing on the first entry when the +/// current value is not in the list (a hand-edited config value). +fn cycle_value(choices: &[String], current: &str, delta: i32) -> String { + if choices.is_empty() { + return current.to_string(); + } + match choices.iter().position(|c| c == current) { + Some(i) => { + let next = ((i as i32 + delta).rem_euclid(choices.len() as i32)) as usize; + choices[next].clone() + } + None => choices[0].clone(), + } +} + +/// Compact token counts for the menu detail column: 60000 → "60k". +fn format_tokens(n: usize) -> String { + if n >= 1000 && n.is_multiple_of(1000) { + format!("{}k", n / 1000) + } else if n >= 1000 { + format!("{:.1}k", n as f64 / 1000.0) + } else { + n.to_string() + } +} + +/// What happens to the menu after an item fires. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MenuOutcome { + /// Dismiss the menu (navigation and one-shot commands). + Close, + /// Keep it open (value cycling, lifecycle toggles) so the effect is + /// visible in the label and can be stepped again. + Stay, +} + +/// A menu item's effect. Dispatch is on this value, never on a position, so +/// inserting a row cannot silently rebind the ones below it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MenuAction { + Separator, + NewSession, + KillSession, + NextSession, + PrevSession, + ScrollUp, + ScrollDown, + ClearScroll, + ListPipes, + PrefillCommand(String), + Help, + OrchestratorStart, + OrchestratorStop, + OrchestratorRestart, + OrchestratorTogglePause, + OrchestratorToggleHidden, + CyclePersona(i32), + CycleProvider(i32), + PickModel, + CycleContext(i32), + ToggleApproval, + ToggleEvent(String), + ReloadConfig, + PlanningNew, + PlanningOpenPicker, + PlanningCommit, +} + +#[derive(Debug, Clone)] +pub struct MenuItem { + pub label: String, + /// Right-aligned current value, for rows that show state. + pub detail: String, + pub action: MenuAction, + pub enabled: bool, +} + +impl MenuItem { + fn new(label: &str, action: MenuAction) -> MenuItem { + MenuItem { + label: label.to_string(), + detail: String::new(), + action, + enabled: true, + } + } + + fn separator() -> MenuItem { + MenuItem { + label: String::new(), + detail: String::new(), + action: MenuAction::Separator, + enabled: false, + } + } + + fn with_detail(mut self, detail: &str) -> MenuItem { + self.detail = detail.to_string(); + self + } + + fn enabled_if(mut self, cond: bool) -> MenuItem { + self.enabled = cond; + self + } + + /// Rendered width needed for label plus detail. + pub fn width(&self) -> usize { + if self.detail.is_empty() { + self.label.chars().count() + } else { + self.label.chars().count() + self.detail.chars().count() + 3 + } + } +} + +#[derive(Debug, Clone)] +pub struct MenuSection { + pub title: String, + pub items: Vec, +} // ── Chat ────────────────────────────────────────────────────────────────────── @@ -5086,32 +5865,878 @@ impl App { } } -// ── Council ─────────────────────────────────────────────────────────────────── +// ── Planning pane ───────────────────────────────────────────────────────────── impl App { - /// `council ` / `council load ` / `council status` / `council stop` - fn execute_council_command(&mut self, args: &[&str]) { - let result = match args { - [] | ["status"] => { - self.command_result = match &self.council { - Some(r) => format!( - "council '{}': round {}/{}{}", - r.group, - r.round, - r.max_rounds, - if r.complete { " [complete]" } else { "" }, - ), - None => "no council running — usage: council ".to_string(), - }; - self.mode = AppMode::CommandResult; + /// Dock the planning pane into a dedicated pane, splitting the focused one + /// to make room. Focus moves to the planning pane. + /// + /// Unlike the chat, planning has no overlay form: a planning thread is a + /// document you sit in beside a session, so it is always a real leaf. + pub fn dock_planning(&mut self, dir: Option) { + if let Some(pane) = self.planning_docked { + // Already docked — just focus it. + self.focused_pane = pane; + self.needs_redraw = true; + return; + } + let panes_before = self.panes.len(); + self.split_focused(dir.unwrap_or(SplitDir::Row)); + if self.panes.len() == panes_before { + // Couldn't split (pane cap reached) — nothing to dock into. + return; + } + self.panes[self.focused_pane] = None; + self.planning_docked = Some(self.focused_pane); + self.planning.refresh_list(); + if self.planning.backend.is_none() { + self.planning.backend = self.config.planning.default_backend(); + } + // With no thread open, the list is the only thing to act on. + self.planning.focus = if self.planning.thread.is_none() { + PlanningFocus::Sidebar + } else { + PlanningFocus::Transcript + }; + self.needs_redraw = true; + } + + /// Give the planning pane the whole terminal, or hand the space back. + /// + /// Docks the pane first if it is closed, so the key is one action rather + /// than a mode that only works once something else is set up. + pub fn toggle_planning_fullscreen(&mut self) { + if self.planning_docked.is_none() { + self.dock_planning(None); + // A pane cap or a layout that cannot split leaves nothing to + // maximize; don't blank the screen over it. + if self.planning_docked.is_none() { return; } - ["stop"] => { - self.command_result = match self.council.take() { - Some(r) => format!("council '{}' stopped (sessions left running)", r.group), - None => "no council running".to_string(), - }; - self.mode = AppMode::CommandResult; + } + self.planning_fullscreen = !self.planning_fullscreen; + if let Some(pane) = self.planning_docked { + // Fullscreen without focus would eat every key and answer none. + self.focused_pane = pane; + } + self.needs_redraw = true; + } + + /// Close the planning pane, giving the space back to its sibling. + pub fn undock_planning(&mut self) { + let Some(pane) = self.planning_docked.take() else { + return; + }; + self.planning_fullscreen = false; + self.planning.picker = None; + self.planning.picker_model = None; + self.planning.confirm_delete = None; + self.focused_pane = pane; + self.close_focused_pane(); + self.needs_redraw = true; + } + + /// Keys for the docked planning pane. + /// + /// Three transient sub-modes take priority over the pane itself: the + /// delete confirmation, the backend picker, and the overflow prompt. The + /// overflow prompt is the only route to `planning_compact`, deliberately — + /// compacting silently would eat the early turns, which in a planning + /// thread are usually the premises everything downstream rests on. + pub fn planning_key(&mut self, key: crossterm::event::KeyEvent) { + use crossterm::event::{KeyCode, KeyModifiers}; + + // ── Delete confirmation ──────────────────────────────────────────── + if self.planning.confirm_delete.is_some() { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + if let Some(id) = self.planning.confirm_delete.take() { + match crate::planning::store::delete(&id) { + Ok(()) => { + if self.planning_is_open(&id) { + self.planning.thread = None; + } + self.planning.refresh_list(); + } + Err(e) => self.planning.error = e.to_string(), + } + } + } + _ => self.planning.confirm_delete = None, + } + self.needs_redraw = true; + return; + } + + // ── Handoff picker ───────────────────────────────────────────────── + if let Some(sel) = self.planning.handoff { + let targets = self.planning_handoff_targets(); + match key.code { + KeyCode::Esc => self.planning.handoff = None, + KeyCode::Up => self.planning.handoff = Some(sel.saturating_sub(1)), + KeyCode::Down => { + self.planning.handoff = Some((sel + 1).min(targets.len().saturating_sub(1))); + } + KeyCode::Enter => { + if let Some((id, _)) = targets.get(sel) { + self.planning_handoff_to(*id); + } + self.planning.handoff = None; + } + _ => {} + } + self.needs_redraw = true; + return; + } + + // ── Backend picker ───────────────────────────────────────────────── + if let Some(sel) = self.planning.picker { + let names = self.config.planning.backend_names(); + let models = names + .get(sel) + .and_then(|n| self.planning.model_cache.get(n)) + .cloned() + .unwrap_or_default(); + + // ── Model level ──────────────────────────────────────────────── + if let Some(msel) = self.planning.picker_model { + match key.code { + // Back out to the endpoint list rather than closing, so a + // wrong endpoint costs one keypress. + KeyCode::Esc | KeyCode::Left => self.planning.picker_model = None, + KeyCode::Up => { + self.planning.picker_model = Some(msel.saturating_sub(1)); + } + KeyCode::Down => { + self.planning.picker_model = + Some((msel + 1).min(models.len().saturating_sub(1))); + } + KeyCode::Enter => { + if let (Some(name), Some(model)) = (names.get(sel), models.get(msel)) { + if let Some(mut b) = self.config.planning.backend(name) { + b.model = model.clone(); + self.planning.backend = Some(b); + self.planning_recheck_budget(); + } + } + self.planning.picker = None; + self.planning.picker_model = None; + } + _ => {} + } + self.needs_redraw = true; + return; + } + + // ── Endpoint level ───────────────────────────────────────────── + match key.code { + KeyCode::Esc => self.planning.picker = None, + KeyCode::Up => { + self.planning.picker = Some(sel.saturating_sub(1)); + } + KeyCode::Down => { + self.planning.picker = Some((sel + 1).min(names.len().saturating_sub(1))); + } + // A local server can load a different model between opens, so + // the cache has to be droppable without restarting linkshell. + KeyCode::Char('r') => { + self.planning.model_cache.clear(); + self.planning_refresh_model_cache(); + } + KeyCode::Enter | KeyCode::Right => { + // Descend into the model list when the endpoint told us + // what it serves; otherwise Enter takes the endpoint with + // its configured model, which is all there is to take. + if !models.is_empty() { + self.planning.picker_model = Some( + names + .get(sel) + .and_then(|n| self.config.planning.backend(n)) + .and_then(|b| models.iter().position(|m| *m == b.model)) + .unwrap_or(0), + ); + } else if key.code == KeyCode::Enter { + if let Some(name) = names.get(sel) { + if let Some(b) = self.config.planning.backend(name) { + self.planning.backend = Some(b); + // Re-check the budget on switch: you find out a + // thread won't fit the local model at switch time, + // not at send time. + self.planning_recheck_budget(); + } + } + self.planning.picker = None; + } + } + _ => {} + } + self.needs_redraw = true; + return; + } + + // ── Overflow prompt ──────────────────────────────────────────────── + if self.planning.overflow { + match key.code { + KeyCode::Char('c') => { + self.planning_compact(); + self.needs_redraw = true; + return; + } + KeyCode::Char('b') => { + self.planning_open_picker(); + self.needs_redraw = true; + return; + } + KeyCode::Esc => { + self.planning.overflow = false; + self.needs_redraw = true; + return; + } + // Anything else falls through to the pane. + _ => {} + } + } + + let alt = key.modifiers.contains(KeyModifiers::ALT); + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + // ── Pane-wide chords ─────────────────────────────────────────────── + match key.code { + KeyCode::Char('b') if ctrl => { + self.planning.sidebar_collapsed = !self.planning.sidebar_collapsed; + if self.planning.sidebar_collapsed { + self.planning.focus = PlanningFocus::Transcript; + } + self.needs_redraw = true; + return; + } + // Alt+M, not Ctrl+M: terminals encode Ctrl+M as carriage return, + // making it indistinguishable from Enter — the picker would never + // fire and the message would send instead. + KeyCode::Char('m') if alt => { + self.planning_open_picker(); + self.needs_redraw = true; + return; + } + KeyCode::Char('k') if ctrl => { + self.planning_commit(); + self.needs_redraw = true; + return; + } + // Hand the committed plan to a session as work. + KeyCode::Char('i') if alt => { + self.planning_open_handoff(); + self.needs_redraw = true; + return; + } + KeyCode::Tab => { + self.planning.focus = match self.planning.focus { + PlanningFocus::Transcript if !self.planning.sidebar_collapsed => { + PlanningFocus::Sidebar + } + _ => PlanningFocus::Transcript, + }; + self.needs_redraw = true; + return; + } + KeyCode::Esc => { + // Fullscreen: step back to the split first. Focusing another + // pane while this one covers the screen would send keys + // somewhere the user cannot see. + if self.planning_fullscreen { + self.planning_fullscreen = false; + } else { + // Docked pane: Esc jumps back to the other pane rather than + // closing the thread. + self.focus_next_pane(); + } + self.needs_redraw = true; + return; + } + KeyCode::PageUp => { + self.planning.scroll = self.planning.scroll.saturating_add(10); + self.needs_redraw = true; + return; + } + KeyCode::PageDown => { + self.planning.scroll = self.planning.scroll.saturating_sub(10); + self.needs_redraw = true; + return; + } + _ => {} + } + + // ── Sidebar focus ────────────────────────────────────────────────── + if self.planning.focus == PlanningFocus::Sidebar { + match key.code { + KeyCode::Up => { + self.planning.list_selected = self.planning.list_selected.saturating_sub(1); + } + KeyCode::Down => { + let last = self.planning.threads.len().saturating_sub(1); + self.planning.list_selected = (self.planning.list_selected + 1).min(last); + } + KeyCode::Enter => { + if let Some(t) = self.planning.threads.get(self.planning.list_selected) { + let id = t.id.clone(); + if let Err(e) = self.planning_open_thread(&id) { + self.planning.error = e; + } + self.planning.focus = PlanningFocus::Transcript; + } + } + KeyCode::Char('n') => self.open_planning_root_browser(), + KeyCode::Char('d') => { + if let Some(t) = self.planning.threads.get(self.planning.list_selected) { + self.planning.confirm_delete = Some(t.id.clone()); + } + } + _ => {} + } + self.needs_redraw = true; + return; + } + + // ── Transcript / input focus ─────────────────────────────────────── + match key.code { + // A planning message is often a paragraph, which is the opposite + // of the orchestrator chat's one-liners. + KeyCode::Enter if alt => { + self.planning.input.insert(self.planning.cursor, '\n'); + self.planning.cursor += 1; + } + KeyCode::Enter => self.planning_send(), + KeyCode::Up => { + self.planning.scroll = self.planning.scroll.saturating_add(1); + } + KeyCode::Down => { + self.planning.scroll = self.planning.scroll.saturating_sub(1); + } + KeyCode::Left => { + let mut i = self.planning.cursor.saturating_sub(1); + while i > 0 && !self.planning.input.is_char_boundary(i) { + i -= 1; + } + self.planning.cursor = i; + } + KeyCode::Right => { + let mut i = (self.planning.cursor + 1).min(self.planning.input.len()); + while i < self.planning.input.len() && !self.planning.input.is_char_boundary(i) { + i += 1; + } + self.planning.cursor = i; + } + KeyCode::Home => self.planning.cursor = 0, + KeyCode::End => self.planning.cursor = self.planning.input.len(), + KeyCode::Backspace if self.planning.cursor > 0 => { + let mut i = self.planning.cursor - 1; + while i > 0 && !self.planning.input.is_char_boundary(i) { + i -= 1; + } + self.planning + .input + .replace_range(i..self.planning.cursor, ""); + self.planning.cursor = i; + } + KeyCode::Delete if self.planning.cursor < self.planning.input.len() => { + let mut i = self.planning.cursor + 1; + while i < self.planning.input.len() && !self.planning.input.is_char_boundary(i) { + i += 1; + } + self.planning + .input + .replace_range(self.planning.cursor..i, ""); + } + KeyCode::Char(c) if !ctrl && !alt => { + self.planning.input.insert(self.planning.cursor, c); + self.planning.cursor += c.len_utf8(); + } + _ => {} + } + self.needs_redraw = true; + } + + /// Sessions a plan can be handed to: live, visible, and not the pane + /// itself. Returns `(session id, display name)`. + pub fn planning_handoff_targets(&self) -> Vec<(usize, String)> { + self.sessions + .iter() + .filter(|s| !s.hidden && s.state != SessionState::Dead) + .map(|s| (s.id, s.name.clone())) + .collect() + } + + /// Open the handoff picker for the thread's latest committed plan. + /// + /// Refuses when there is nothing to hand off: the contract with an + /// implementation session is a plan *file*, so an uncommitted thread has + /// nothing to give it. + fn planning_open_handoff(&mut self) { + let Some(thread) = self.planning.thread.as_ref() else { + return; + }; + if crate::planning::store::latest_plan(&thread.id).is_none() { + self.planning.error = "no committed plan yet — ctrl-k to commit one".to_string(); + return; + } + if self.planning_handoff_targets().is_empty() { + self.planning.error = "no live session to hand the plan to".to_string(); + return; + } + self.planning.error.clear(); + self.planning.handoff = Some(0); + } + + /// Hand the latest committed plan to `dest_id` as a work brief. + /// + /// The brief is a path plus a staleness warning, not a serialized thread: + /// an implementation session may run sandboxed, and a read-only bind mount + /// of one file is far simpler to arrange than replaying a conversation. + /// Staleness is recomputed now rather than reused from commit time, since + /// the repo may have moved on since. + fn planning_handoff_to(&mut self, dest_id: usize) { + let Some(thread) = self.planning.thread.as_ref() else { + return; + }; + let Some(path) = crate::planning::store::latest_plan(&thread.id) else { + self.planning.error = "no committed plan yet — ctrl-k to commit one".to_string(); + return; + }; + let stale = thread.stale_reads(); + let brief = crate::planning::distill::session_brief(&path, &stale); + let name = self + .sessions + .iter() + .find(|s| s.id == dest_id) + .map(|s| s.name.clone()) + .unwrap_or_default(); + // Reuse the pipe relay: it already queues for a session that isn't + // Ready, which is the common case when handing work to a busy agent. + self.handle_pipe_relay(dest_id, format!("{}\n", brief)); + self.planning.status = format!("plan handed to {}", name); + self.planning.error.clear(); + } + + /// Open the backend picker positioned on the current backend. + fn planning_open_picker(&mut self) { + let names = self.config.planning.backend_names(); + let cur = self + .planning + .backend + .as_ref() + .and_then(|b| names.iter().position(|n| n == &b.name)) + .unwrap_or(0); + self.planning.picker = Some(cur); + self.planning.picker_model = None; + // Probing is cached per backend; a local server's model list changes + // rarely and the picker would otherwise hit the endpoint every open. + self.planning_refresh_model_cache(); + } + + /// Probe backends' `/v1/models` for the picker, once per backend. + /// + /// Hosted endpoints are skipped: they advertise hundreds of models, which + /// is noise in a picker, and their catalogue is not what changes under + /// you. A self-hosted server's is — it serves whatever is loaded right + /// now, which is exactly the thing worth asking about rather than + /// hardcoding in config. + fn planning_refresh_model_cache(&mut self) { + for name in self.config.planning.backend_names() { + if self.planning.model_cache.contains_key(&name) { + continue; + } + let Some(b) = self.config.planning.backend(&name) else { + continue; + }; + if !b.is_probeable() { + continue; + } + let url = format!("{}/models", b.endpoint_url().trim_end_matches('/')); + let tx = self.event_tx.clone(); + let key = b.resolve_api_key(); + tokio::spawn(async move { + let models = probe_models(&url, key.as_deref()).await; + let _ = tx + .send(crate::events::AppEvent::PlanningModels { + backend: name, + models, + }) + .await; + }); + } + } + + /// Open the orchestrator's model picker, probing its endpoint first. + /// + /// The Model row used to cycle `[orchestrator].models`, which is empty in + /// most configs — leaving a one-element list, so pressing Enter set the + /// model to what it already was and the row looked dead. Ask the endpoint + /// instead; a local server knows what it is serving and the config does + /// not. + pub fn open_orchestrator_model_picker(&mut self) { + let orch = &self.config.orchestrator; + // Start from whatever the config lists, so a hosted provider (whose + // catalogue is not worth enumerating) still offers its choices. + self.orchestrator_models = orch.model_choices(); + let selected = self + .orchestrator_models + .iter() + .position(|m| *m == orch.model) + .unwrap_or(0); + self.mode = AppMode::OrchestratorModel { selected }; + self.refresh_orchestrator_models(); + self.needs_redraw = true; + } + + /// Ask the orchestrator's endpoint what it is serving. Only self-hosted + /// endpoints are asked, for the same reason the planning picker skips the + /// hosted catalogues: hundreds of entries is not a choice. + pub fn refresh_orchestrator_models(&mut self) { + let orch = &self.config.orchestrator; + if !matches!(orch.class(), Ok(crate::config::OrchestratorClass::Api(_))) { + return; + } + let endpoint = orch.endpoint_url(); + if endpoint.is_empty() + || endpoint.contains("api.openai.com") + || orch.provider == "anthropic" + { + return; + } + let url = format!("{}/models", endpoint.trim_end_matches('/')); + let key = orch.resolve_api_key(); + let tx = self.event_tx.clone(); + tokio::spawn(async move { + let models = probe_models(&url, key.as_deref()).await; + let _ = tx + .send(crate::events::AppEvent::OrchestratorModels { models }) + .await; + }); + } + + pub fn handle_orchestrator_models(&mut self, models: Vec) { + if models.is_empty() { + return; + } + // Keep the configured model listed even when the server does not + // report it, or the picker cannot get back to where it started. + let current = self.config.orchestrator.model.clone(); + let mut list = models; + if !current.is_empty() && !list.contains(¤t) { + list.insert(0, current.clone()); + } + if let AppMode::OrchestratorModel { .. } = self.mode { + let selected = list.iter().position(|m| *m == current).unwrap_or(0); + self.mode = AppMode::OrchestratorModel { selected }; + } + self.orchestrator_models = list; + self.needs_redraw = true; + } + + /// Apply the highlighted model. Takes effect on the orchestrator's next + /// restart, which the menu already advertises as a pending change. + pub fn orchestrator_model_picker_select(&mut self, idx: usize) { + let Some(model) = self.orchestrator_models.get(idx).cloned() else { + self.mode = AppMode::Normal; + return; + }; + self.config_mut().orchestrator.model = model.clone(); + self.mark_orchestrator_dirty(); + self.command_result = format!("orchestrator model → {} (restart to apply)", model); + self.mode = AppMode::CommandResult; + self.needs_redraw = true; + } + + pub fn handle_planning_models(&mut self, backend: String, models: Vec) { + self.planning.model_cache.insert(backend, models); + self.needs_redraw = true; + } + + /// Re-evaluate the thread against the selected backend's window. + fn planning_recheck_budget(&mut self) { + let (Some(thread), Some(backend)) = ( + self.planning.thread.as_ref(), + self.planning.backend.as_ref(), + ) else { + return; + }; + match crate::planning::check_budget(thread, backend, &self.planning.input) { + Ok(_) => { + self.planning.overflow = false; + self.planning.error.clear(); + } + Err(e) => { + self.planning.overflow = true; + self.planning.error = e.to_string(); + } + } + } + + /// Open a new planning thread grounded in `root`. + /// + /// The root is canonicalized and pinned now. Reopening the thread later + /// reuses this value rather than re-deriving it from wherever the pane + /// happens to be opened, which would quietly invalidate every file + /// citation in the conversation. + pub fn planning_new_thread(&mut self, title: &str, root: &str) -> Result<(), String> { + let expanded = if root.trim().is_empty() { + "." + } else { + root.trim() + }; + let path = std::path::PathBuf::from(expanded); + let canonical = crate::planning::tools::canonical_root(&path).map_err(|e| e.to_string())?; + let thread = crate::planning::store::Thread::new(title, canonical); + crate::planning::store::save(&thread).map_err(|e| e.to_string())?; + self.planning.thread = Some(thread); + self.planning.input.clear(); + self.planning.cursor = 0; + self.planning.scroll = 0; + self.planning.error.clear(); + self.planning.overflow = false; + self.planning.last_peak_tokens = 0; + if self.planning.backend.is_none() { + self.planning.backend = self.config.planning.default_backend(); + } + self.planning.refresh_list(); + self.needs_redraw = true; + Ok(()) + } + + /// Open an existing thread by id. + pub fn planning_open_thread(&mut self, id: &str) -> Result<(), String> { + let thread = crate::planning::store::load(id).map_err(|e| e.to_string())?; + self.planning.thread = Some(thread); + self.planning.scroll = 0; + self.planning.error.clear(); + self.planning.overflow = false; + // The peak belongs to the turn that produced it, not to the pane. + self.planning.last_peak_tokens = 0; + if self.planning.backend.is_none() { + self.planning.backend = self.config.planning.default_backend(); + } + self.needs_redraw = true; + Ok(()) + } + + /// Send the pane's input as a planning turn. + /// + /// The draft is cleared only once the request is in flight, and comes back + /// on failure — a local endpoint going down mid-thought must not cost the + /// user their typed message. + pub fn planning_send(&mut self) { + if self.planning.busy { + return; + } + let text = self.planning.input.trim().to_string(); + if text.is_empty() { + return; + } + let thread = match &self.planning.thread { + Some(t) => t.clone(), + None => { + self.planning.error = "no thread open".to_string(); + return; + } + }; + let backend = match &self.planning.backend { + Some(b) => b.clone(), + None => { + self.planning.error = + "no planning backend configured — add [planning.backends.NAME]".to_string(); + return; + } + }; + // Catch an overflow here rather than after a round trip, so the + // compact/fork choice is offered before anything is spent. + if let Err(e) = crate::planning::check_budget(&thread, &backend, &text) { + self.planning.error = e.to_string(); + self.planning.overflow = + matches!(e, crate::planning::TurnError::ContextOverflow { .. }); + self.needs_redraw = true; + return; + } + + self.planning.input.clear(); + self.planning.cursor = 0; + self.planning.busy = true; + self.planning.status = "sending".to_string(); + self.planning.error.clear(); + self.planning.overflow = false; + self.planning.scroll = 0; + crate::planning::spawn_turn(thread, backend, text, self.event_tx.clone()); + self.needs_redraw = true; + } + + /// Drop oldest turns until the thread fits the selected backend. Only + /// reachable from an explicit user action after an overflow. + pub fn planning_compact(&mut self) { + let backend = match &self.planning.backend { + Some(b) => b.clone(), + None => return, + }; + let pending = self.planning.input.clone(); + if let Some(thread) = self.planning.thread.as_mut() { + let dropped = crate::planning::compact(thread, &backend, &pending); + let _ = crate::planning::store::save(thread); + self.planning.status = format!("compacted: dropped {} turns", dropped); + self.planning.error.clear(); + self.planning.overflow = false; + self.needs_redraw = true; + } + } + + /// Distill the open thread into a plan revision. + pub fn planning_commit(&mut self) { + if self.planning.busy { + return; + } + let thread = match &self.planning.thread { + Some(t) => t.clone(), + None => return, + }; + let distiller = match self.config.planning.distill_backend() { + Some(b) => b, + None => { + self.planning.error = "no distill backend configured".to_string(); + return; + } + }; + self.planning.busy = true; + self.planning.status = format!("distilling with {}", distiller.label()); + crate::planning::distill::spawn_commit(thread, distiller, self.event_tx.clone()); + self.needs_redraw = true; + } + + pub fn handle_planning_status(&mut self, thread_id: String, status: String) { + if self.planning_is_open(&thread_id) { + self.planning.status = status; + self.needs_redraw = true; + } + } + + pub fn handle_planning_reply( + &mut self, + thread_id: String, + text: String, + backend: String, + model: String, + peak_tokens: usize, + save_error: Option, + ) { + if !self.planning_is_open(&thread_id) { + return; + } + self.planning.busy = false; + self.planning.last_peak_tokens = peak_tokens; + self.planning.status.clear(); + // The background task owns its own copy of the thread, so reload from + // disk rather than appending here — that keeps one writer and avoids + // the two copies diverging. + if let Ok(t) = crate::planning::store::load(&thread_id) { + self.planning.thread = Some(t); + } else if let Some(t) = self.planning.thread.as_mut() { + let b = crate::planning::Backend { + name: backend, + model, + ..crate::planning::Backend::default() + }; + t.messages + .push(crate::planning::store::Message::assistant(text, &b)); + } + if let Some(e) = save_error { + self.planning.error = format!("reply not saved: {}", e); + } + self.planning.scroll = 0; + self.planning.refresh_list(); + self.needs_redraw = true; + } + + pub fn handle_planning_failed( + &mut self, + thread_id: String, + draft: String, + error: String, + overflow: bool, + ) { + if !self.planning_is_open(&thread_id) { + return; + } + self.planning.busy = false; + self.planning.status.clear(); + self.planning.error = error; + self.planning.overflow = overflow; + // Restore the unsent message so a switch-and-retry costs nothing. + if !draft.is_empty() && self.planning.input.trim().is_empty() { + self.planning.cursor = draft.chars().count(); + self.planning.input = draft; + } + self.needs_redraw = true; + } + + pub fn handle_planning_committed( + &mut self, + thread_id: String, + path: String, + revision: usize, + stale: Vec, + ) { + if !self.planning_is_open(&thread_id) { + return; + } + self.planning.busy = false; + self.planning.status = if stale.is_empty() { + format!("plan revision {} written to {}", revision, path) + } else { + format!( + "plan revision {} written to {} — grounded in {} file(s) that have since changed: {}", + revision, + path, + stale.len(), + stale.join(", ") + ) + }; + if let Ok(t) = crate::planning::store::load(&thread_id) { + self.planning.thread = Some(t); + } + self.needs_redraw = true; + } + + fn planning_is_open(&self, thread_id: &str) -> bool { + self.planning + .thread + .as_ref() + .map(|t| t.id == thread_id) + .unwrap_or(false) + } +} + +// ── Council ─────────────────────────────────────────────────────────────────── + +impl App { + /// `council ` / `council load ` / `council status` / `council stop` + fn execute_council_command(&mut self, args: &[&str]) { + let result = match args { + [] | ["status"] => { + self.command_result = match &self.council { + Some(r) => format!( + "council '{}': round {}/{}{}", + r.group, + r.round, + r.max_rounds, + if r.complete { " [complete]" } else { "" }, + ), + None => "no council running — usage: council ".to_string(), + }; + self.mode = AppMode::CommandResult; + return; + } + ["stop"] => { + self.command_result = match self.council.take() { + Some(r) => format!("council '{}' stopped (sessions left running)", r.group), + None => "no council running".to_string(), + }; + self.mode = AppMode::CommandResult; return; } ["load", path] | [path] => self.load_council_file(path), @@ -5964,6 +7589,479 @@ mod tests { app.new_session_state.cursor_pos() } + // ── Menu ────────────────────────────────────────────────────────────── + + fn find_item(app: &App, section: &str, label: &str) -> (usize, usize, MenuItem) { + let sections = app.menu(); + let si = sections + .iter() + .position(|s| s.title == section) + .unwrap_or_else(|| panic!("no {} section", section)); + let ii = sections[si] + .items + .iter() + .position(|i| i.label == label) + .unwrap_or_else(|| panic!("no {} item in {}", label, section)); + (si, ii, sections[si].items[ii].clone()) + } + + #[test] + fn menu_section_mnemonics_are_unique() { + let app = make_app(); + let mut firsts: Vec = app + .menu() + .iter() + .map(|s| s.title.chars().next().unwrap().to_ascii_lowercase()) + .collect(); + let before = firsts.len(); + firsts.sort_unstable(); + firsts.dedup(); + assert_eq!(before, firsts.len(), "two sections share a mnemonic letter"); + } + + #[test] + fn mnemonic_jumps_to_the_named_section() { + let mut app = make_app(); + app.open_menu(); + assert!(app.menu_jump_to_mnemonic('o')); + match app.mode { + AppMode::Menu { selected_top, .. } => { + assert_eq!(app.menu()[selected_top].title, "Orchestrator"); + } + _ => panic!("expected menu mode"), + } + assert!(!app.menu_jump_to_mnemonic('z'), "unknown letter is a no-op"); + } + + #[test] + fn orchestrator_row_flips_between_start_and_stop() { + let mut app = make_app(); + let (_, _, item) = find_item(&app, "Orchestrator", "Start"); + assert_eq!(item.action, MenuAction::OrchestratorStart); + // Restart and Pause are meaningless with nothing running. + assert!(!find_item(&app, "Orchestrator", "Restart").2.enabled); + + app.orchestrator_session_id = Some(1); + let (_, _, item) = find_item(&app, "Orchestrator", "Stop"); + assert_eq!(item.action, MenuAction::OrchestratorStop); + assert!(find_item(&app, "Orchestrator", "Restart").2.enabled); + } + + #[test] + fn disabled_rows_are_no_ops_that_keep_the_menu_open() { + let mut app = make_app(); + app.open_menu(); + let (si, ii, item) = find_item(&app, "Orchestrator", "Restart"); + assert!(!item.enabled); + app.activate_menu_index(si, ii); + assert!( + matches!(app.mode, AppMode::Menu { .. }), + "a greyed-out row must not dismiss the menu" + ); + } + + /// The Model row cycled `[orchestrator].models`, which is empty in most + /// configs — a one-element list, so Enter set the model to what it + /// already was and the row looked dead. It opens a picker now. + #[test] + fn the_model_row_opens_a_picker_positioned_on_the_current_model() { + let mut cfg = crate::config::Config::default(); + cfg.orchestrator.model = "b".into(); + cfg.orchestrator.models = vec!["a".into(), "b".into(), "c".into()]; + let mut app = make_app_with_config(cfg); + app.open_menu(); + + let (si, ii, _) = find_item(&app, "Orchestrator", "Model"); + app.activate_menu_index(si, ii); + assert!( + matches!(app.mode, AppMode::OrchestratorModel { selected: 1 }), + "opens on the configured model, got {:?}", + app.mode + ); + assert_eq!(app.orchestrator_models, vec!["a", "b", "c"]); + } + + /// A config that never listed models — the common case — must still get a + /// usable picker once the endpoint answers. + #[test] + fn probed_models_populate_a_picker_that_config_left_empty() { + let mut cfg = crate::config::Config::default(); + cfg.orchestrator.model = "loaded-model".into(); + let mut app = make_app_with_config(cfg); + app.open_orchestrator_model_picker(); + // Only the configured model, which is why cycling could never move. + assert_eq!(app.orchestrator_models, vec!["loaded-model"]); + + app.handle_orchestrator_models(vec!["other-model".into(), "loaded-model".into()]); + assert_eq!( + app.orchestrator_models, + vec!["other-model", "loaded-model"], + "the endpoint's list replaces the stub" + ); + assert!( + matches!(app.mode, AppMode::OrchestratorModel { selected: 1 }), + "still positioned on the model actually in use" + ); + } + + /// The configured model has to stay listed even when the server does not + /// report it, or the picker cannot get back to where it started. + #[test] + fn a_model_the_server_does_not_report_is_still_offered() { + let mut cfg = crate::config::Config::default(); + cfg.orchestrator.model = "unloaded".into(); + let mut app = make_app_with_config(cfg); + app.open_orchestrator_model_picker(); + app.handle_orchestrator_models(vec!["something-else".into()]); + assert_eq!(app.orchestrator_models, vec!["unloaded", "something-else"]); + assert!(matches!( + app.mode, + AppMode::OrchestratorModel { selected: 0 } + )); + } + + #[test] + fn selecting_a_model_edits_config_and_flags_a_restart_only_when_running() { + let mut cfg = crate::config::Config::default(); + cfg.orchestrator.model = "a".into(); + cfg.orchestrator.models = vec!["a".into(), "b".into()]; + let mut app = make_app_with_config(cfg); + + app.open_orchestrator_model_picker(); + app.orchestrator_model_picker_select(1); + assert_eq!(app.config.orchestrator.model, "b"); + // Nothing is running, so no restart is owed. + assert!(!app.orchestrator_config_dirty); + + app.orchestrator_session_id = Some(1); + app.open_orchestrator_model_picker(); + app.orchestrator_model_picker_select(0); + assert_eq!(app.config.orchestrator.model, "a"); + assert!( + app.orchestrator_config_dirty, + "a running orchestrator holds a snapshot, so a restart is owed" + ); + } + + #[test] + fn a_pending_restart_row_appears_only_while_changes_are_unapplied() { + let mut cfg = crate::config::Config::default(); + cfg.orchestrator.models = vec!["a".into(), "b".into()]; + let mut app = make_app_with_config(cfg); + app.orchestrator_session_id = Some(1); + assert!(app + .menu() + .iter() + .flat_map(|s| &s.items) + .all(|i| i.label != "Restart To Apply Changes")); + + app.open_orchestrator_model_picker(); + app.orchestrator_model_picker_select(1); + assert!(app + .menu() + .iter() + .flat_map(|s| &s.items) + .any(|i| i.label == "Restart To Apply Changes")); + } + + #[test] + fn context_budget_cycles_through_the_ladder_and_shows_a_compact_label() { + let mut cfg = crate::config::Config::default(); + cfg.orchestrator.max_context_tokens = 60_000; + let mut app = make_app_with_config(cfg); + let (si, ii, item) = find_item(&app, "Orchestrator", "Context Budget"); + assert_eq!(item.detail, "60k"); + app.open_menu(); + app.activate_menu_index(si, ii); + assert_ne!(app.config.orchestrator.max_context_tokens, 60_000); + // 0 means unlimited and must be reachable, so compaction can be + // turned off without editing the file. + let choices = app.config.orchestrator.context_choices(); + assert!(choices.contains(&0)); + } + + #[test] + fn hand_edited_config_values_stay_reachable_when_cycling() { + let mut cfg = crate::config::Config::default(); + cfg.orchestrator.model = "custom-not-in-list".into(); + cfg.orchestrator.models = vec!["a".into(), "b".into()]; + cfg.orchestrator.max_context_tokens = 12_345; + let app = make_app_with_config(cfg); + assert!(app + .config + .orchestrator + .model_choices() + .contains(&"custom-not-in-list".to_string())); + assert!(app.config.orchestrator.context_choices().contains(&12_345)); + } + + #[test] + fn persona_cycles_without_owing_a_restart() { + let mut app = make_app(); + app.orchestrator_session_id = Some(1); + let (si, ii, item) = find_item(&app, "Orchestrator", "Persona"); + assert_eq!(item.detail, "(none)"); + app.open_menu(); + app.activate_menu_index(si, ii); + assert!( + !app.orchestrator_persona.is_empty(), + "a persona became active" + ); + assert!( + !app.orchestrator_config_dirty, + "personas layer over the base config and apply on the next turn" + ); + } + + #[test] + fn approval_toggles_between_auto_and_propose() { + let mut app = make_app(); + let (si, ii, item) = find_item(&app, "Orchestrator", "Approval"); + assert_eq!(item.detail, "auto"); + app.open_menu(); + app.activate_menu_index(si, ii); + assert_eq!(app.config.orchestrator.approval, "propose"); + app.activate_menu_index(si, ii); + assert_eq!(app.config.orchestrator.approval, "auto"); + } + + #[test] + fn wake_events_toggle_individually() { + let mut app = make_app(); + let (si, ii, item) = find_item(&app, "Orchestrator", "Wake on ready"); + assert_eq!(item.detail, "off", "ready is off by default — it is costly"); + app.open_menu(); + app.activate_menu_index(si, ii); + assert!(app.config.orchestrator.events.iter().any(|e| e == "ready")); + assert!( + app.config.orchestrator.events.iter().any(|e| e == "error"), + "toggling one event must not disturb the others" + ); + app.activate_menu_index(si, ii); + assert!(!app.config.orchestrator.events.iter().any(|e| e == "ready")); + } + + #[test] + fn arrow_navigation_steps_over_separators() { + let mut app = make_app(); + let sections = app.menu(); + let oi = sections + .iter() + .position(|s| s.title == "Orchestrator") + .unwrap(); + app.mode = AppMode::Menu { + selected_top: oi, + selected_sub: Some(0), + }; + // Walk the whole section; a separator must never be the resting spot. + for _ in 0..sections[oi].items.len() * 2 { + app.menu_move_sub(1); + let AppMode::Menu { selected_sub, .. } = app.mode else { + panic!("left menu mode") + }; + let idx = selected_sub.unwrap(); + assert_ne!( + app.menu()[oi].items[idx].action, + MenuAction::Separator, + "parked on a separator at {}", + idx + ); + } + } + + // ── Planning fullscreen ─────────────────────────────────────────────── + + /// The key is one action: it docks the pane if it is closed rather than + /// being a mode that only works once something else is set up. + #[test] + fn fullscreen_docks_the_pane_if_it_is_closed_and_focuses_it() { + let mut app = make_app(); + assert!(app.planning_docked.is_none()); + app.toggle_planning_fullscreen(); + assert!(app.planning_docked.is_some(), "docked on the way in"); + assert!(app.planning_fullscreen); + assert_eq!( + Some(app.focused_pane), + app.planning_docked, + "fullscreen without focus would eat every key and answer none" + ); + } + + /// Esc steps back to the split before it starts moving focus, or keys go + /// to a pane hidden behind this one. + #[test] + fn esc_leaves_fullscreen_before_it_changes_focus() { + use crossterm::event::KeyCode; + let mut app = make_app(); + app.toggle_planning_fullscreen(); + let pane = app.planning_docked.expect("docked"); + app.planning_key(key(KeyCode::Esc)); + assert!(!app.planning_fullscreen); + assert_eq!(app.focused_pane, pane, "focus stays put on the first Esc"); + app.planning_key(key(KeyCode::Esc)); + assert_ne!(app.focused_pane, pane, "now it moves on"); + } + + /// Focusing away from a fullscreen planning pane has to restore the split. + #[test] + fn focusing_another_pane_drops_fullscreen() { + let mut app = make_app(); + app.toggle_planning_fullscreen(); + app.focus_next_pane(); + assert!(!app.planning_fullscreen); + } + + /// Closing the pane cannot leave the flag set, or the next dock comes back + /// fullscreen unasked. + #[test] + fn undocking_clears_fullscreen() { + let mut app = make_app(); + app.toggle_planning_fullscreen(); + app.undock_planning(); + assert!(!app.planning_fullscreen); + assert!(app.planning_docked.is_none()); + } + + // ── Planning backend/model picker ───────────────────────────────────── + + fn key(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent { + crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE) + } + + /// An endpoint is not a choice of model: the picker has to descend into + /// what the endpoint reported and apply the model the user lands on. + #[test] + fn picking_a_probed_model_overrides_the_configured_one() { + use crossterm::event::KeyCode; + let cfg = crate::config::parse( + "[planning.backends.local]\nprovider = \"lmstudio\"\nendpoint = \"http://localhost:1234/v1\"\nmodel = \"stale-from-config\"\n", + ) + .unwrap(); + let mut app = make_app_with_config(cfg); + // Stand in for the /v1/models probe, which is what the endpoint would + // have answered. + app.handle_planning_models( + "local".to_string(), + vec!["gemma4-26b".to_string(), "qwen3.6-27b".to_string()], + ); + + app.planning.picker = Some(0); + app.planning_key(key(KeyCode::Enter)); // descend into the model list + assert_eq!( + app.planning.picker_model, + Some(0), + "the configured model is not in the list, so land on the first" + ); + app.planning_key(key(KeyCode::Down)); + app.planning_key(key(KeyCode::Enter)); + + let b = app + .planning + .backend + .as_ref() + .expect("a backend was selected"); + assert_eq!(b.name, "local"); + assert_eq!(b.model, "qwen3.6-27b", "the probed model wins over config"); + assert_eq!(app.planning.picker, None, "selecting closes the picker"); + assert_eq!(app.planning.picker_model, None); + } + + /// Left backs out one level instead of closing, so a wrong endpoint costs + /// one keypress rather than a reopen. + #[test] + fn left_returns_to_the_endpoint_list() { + use crossterm::event::KeyCode; + let mut app = make_app(); + app.planning.picker = Some(0); + app.planning.picker_model = Some(1); + app.planning_key(key(KeyCode::Left)); + assert_eq!(app.planning.picker_model, None); + assert_eq!(app.planning.picker, Some(0), "still in the picker"); + } + + /// A backend whose endpoint said nothing still has to be selectable on its + /// configured model, or an unreachable server locks the picker. + #[test] + fn an_unprobed_backend_is_selected_on_its_configured_model() { + use crossterm::event::KeyCode; + let cfg = crate::config::parse( + "[planning.backends.opus]\nprovider = \"anthropic\"\nmodel = \"claude-opus-4-8\"\n", + ) + .unwrap(); + let mut app = make_app_with_config(cfg); + app.planning.picker = Some(0); + app.planning_key(key(KeyCode::Enter)); + assert_eq!(app.planning.picker_model, None, "nothing to descend into"); + let b = app.planning.backend.as_ref().expect("selected anyway"); + assert_eq!(b.model, "claude-opus-4-8"); + assert_eq!(app.planning.picker, None); + } + + #[test] + fn moving_between_sections_clamps_a_stale_submenu_index() { + let mut app = make_app(); + let sections = app.menu(); + let oi = sections + .iter() + .position(|s| s.title == "Orchestrator") + .unwrap(); + let long = sections[oi].items.len(); + app.mode = AppMode::Menu { + selected_top: oi, + selected_sub: Some(long - 1), + }; + // Step to a shorter section; the index must not point past its end. + for _ in 0..sections.len() { + app.menu_move_top(1); + let AppMode::Menu { + selected_top, + selected_sub, + } = app.mode + else { + panic!("left menu mode") + }; + let len = app.menu()[selected_top].items.len(); + assert!( + selected_sub.unwrap() < len, + "stale index leaked into a shorter section" + ); + } + } + + #[test] + fn one_shot_actions_close_the_menu() { + let mut app = make_app(); + app.open_menu(); + let (si, ii, _) = find_item(&app, "Pipes", "List Pipes"); + app.activate_menu_index(si, ii); + assert!(!matches!(app.mode, AppMode::Menu { .. })); + } + + #[test] + fn out_of_range_positions_are_ignored_rather_than_firing_something_else() { + let mut app = make_app(); + app.open_menu(); + let before = app.config.orchestrator.approval.clone(); + app.activate_menu_index(999, 999); + assert_eq!(app.config.orchestrator.approval, before); + } + + #[test] + fn token_counts_render_compactly() { + assert_eq!(format_tokens(60_000), "60k"); + assert_eq!(format_tokens(12_345), "12.3k"); + assert_eq!(format_tokens(512), "512"); + } + + #[test] + fn cycle_value_recovers_from_a_value_outside_the_list() { + let choices: Vec = vec!["a".into(), "b".into()]; + assert_eq!(cycle_value(&choices, "a", 1), "b"); + assert_eq!(cycle_value(&choices, "b", 1), "a"); + assert_eq!(cycle_value(&choices, "zzz", 1), "a"); + assert_eq!(cycle_value(&[], "keep", 1), "keep"); + } + #[test] fn status_rows_grow_immediately_but_shrink_with_hysteresis() { let app = make_app(); diff --git a/src/config.rs b/src/config.rs index 7ac4097..11338b4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,7 @@ pub struct Config { pub notifications: NotificationsConfig, pub orchestrator: OrchestratorConfig, pub chat: ChatConfig, + pub planning: PlanningConfig, pub profiles: Vec, /// User-defined personas; a name matching a builtin replaces it. #[serde(default)] @@ -133,6 +134,15 @@ pub struct OrchestratorConfig { /// Longer replies are truncated to the last N lines with a marker. /// 0 disables. pub wait_ready_max_lines: usize, + /// Models offered by the Orchestrator menu's Model row. The menu cycles + /// this list; it does not query the provider, because the endpoint may be + /// a local server whose loaded model set changes independently of what is + /// worth switching between. + pub models: Vec, + /// Providers offered by the menu's Provider row. Empty = the built-in set. + pub providers: Vec, + /// Context budgets offered by the menu. Empty = a built-in ladder. + pub context_choices: Vec, } impl Default for OrchestratorConfig { @@ -178,6 +188,9 @@ impl Default for OrchestratorConfig { persona: String::new(), tool_dedup_secs: 45, wait_ready_max_lines: 80, + models: Vec::new(), + providers: Vec::new(), + context_choices: Vec::new(), } } } @@ -203,6 +216,98 @@ impl Default for ChatConfig { } } +// ── [planning] ──────────────────────────────────────────────────────────── + +/// The planning pane: a persistent, read-only, single-agent design chat. +/// +/// Backends are selected at runtime from the pane, so several are configured +/// and none is privileged. A thread can be built cheaply on a local model and +/// distilled by a frontier one. +/// +/// [planning] +/// default_backend = "local" +/// distill_backend = "opus" # falls back to default_backend +/// +/// [planning.backends.local] +/// provider = "lmstudio" +/// endpoint = "http://localhost:1234/v1" +/// model = "qwen3.6-27b" +/// max_context_tokens = 28000 +/// +/// [planning.backends.opus] +/// provider = "anthropic" +/// model = "claude-opus-4-8" +/// max_context_tokens = 180000 +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, Default)] +#[serde(default)] +pub struct PlanningConfig { + /// Backend selected when a pane opens. Empty = first by name. + pub default_backend: String, + /// Backend used by the commit step. Empty = `default_backend`. + pub distill_backend: String, + /// Sidebar width as a percentage of the pane (15-50). + pub sidebar_pct: u16, + pub backends: HashMap, + /// Backends inferred from model endpoints configured elsewhere — + /// `[agents.*]` and an API-class `[orchestrator]` (see + /// `Config::derive_planning_backends`). Without these a config that has + /// never named `[planning.backends.*]` offers nothing to pick, and the + /// pane's whole point is picking. Explicit entries shadow them by name. + /// + /// Skipped by serde in both directions: they are not the user's config + /// and must not be written back by `save()` as though they were. + #[serde(skip)] + pub derived: HashMap, +} + +impl PlanningConfig { + /// Backend names in a stable display order for the picker. + pub fn backend_names(&self) -> Vec { + let mut names: Vec = self.backends.keys().cloned().collect(); + for name in self.derived.keys() { + if !self.backends.contains_key(name) { + names.push(name.clone()); + } + } + names.sort(); + names + } + + /// Look up a backend, stamping its config key into `name` — the key is + /// what gets recorded per message, so it must not be lost in + /// deserialization. + pub fn backend(&self, name: &str) -> Option { + self.backends.get(name).or(self.derived.get(name)).map(|b| { + let mut b = b.clone(); + b.name = name.to_string(); + b + }) + } + + /// Backend a newly opened pane starts on. + pub fn default_backend(&self) -> Option { + self.backend(&self.default_backend) + .or_else(|| self.backend_names().first().and_then(|n| self.backend(n))) + } + + /// Backend the commit step distills with. Chosen independently so the + /// artifact that actually gets handed to an agent can use a better model + /// than the conversation did. + pub fn distill_backend(&self) -> Option { + self.backend(&self.distill_backend) + .or_else(|| self.default_backend()) + } + + /// Clamped sidebar width. + pub fn sidebar_width_pct(&self) -> u16 { + if self.sidebar_pct == 0 { + 28 + } else { + self.sidebar_pct.clamp(15, 50) + } + } +} + pub enum ApiProvider { Anthropic, OpenAi, // also serves LM Studio @@ -216,6 +321,51 @@ pub enum OrchestratorClass { impl OrchestratorConfig { /// True if this tool call must be approved by the human before running. + /// Provider names the menu cycles through. + pub fn provider_choices(&self) -> Vec { + if !self.providers.is_empty() { + return self.providers.clone(); + } + [ + "anthropic", + "openai", + "lmstudio", + "claude", + "codex", + "opencode", + ] + .iter() + .map(|s| s.to_string()) + .collect() + } + + /// Models the menu cycles through. Always includes the configured model, + /// so cycling from a hand-edited value can return to it. + pub fn model_choices(&self) -> Vec { + let mut out = self.models.clone(); + if !self.model.is_empty() && !out.contains(&self.model) { + out.insert(0, self.model.clone()); + } + out + } + + /// Context budgets the menu cycles through, in tokens. Always includes + /// the configured value so cycling is reversible, and 0 (unlimited) so + /// compaction can be turned off from the menu. + pub fn context_choices(&self) -> Vec { + let mut out = if self.context_choices.is_empty() { + vec![0, 8_000, 16_000, 32_000, 60_000, 100_000, 180_000] + } else { + self.context_choices.clone() + }; + if !out.contains(&self.max_context_tokens) { + out.push(self.max_context_tokens); + } + out.sort_unstable(); + out.dedup(); + out + } + pub fn approval_required(&self, tool: &str) -> bool { if self.approval != "propose" { return false; @@ -1068,11 +1218,69 @@ pub fn save_profile(profile: &Profile) -> anyhow::Result { } pub fn parse(content: &str) -> anyhow::Result { - let cfg: Config = toml::from_str(content)?; + let mut cfg: Config = toml::from_str(content)?; validate_profiles(&cfg)?; + cfg.derive_planning_backends(); Ok(cfg) } +impl Config { + /// Offer the model endpoints already configured elsewhere as planning + /// backends, so the picker is usable without a second copy of the same + /// endpoint under `[planning.backends.*]`. Explicit entries always win — + /// a derived one is a starting point, not an override. + pub fn derive_planning_backends(&mut self) { + let mut derived: HashMap = HashMap::new(); + + for (name, agent) in &self.agents { + derived.insert( + name.clone(), + crate::planning::Backend { + name: name.clone(), + // `[agents.*]` is defined as OpenAI-compatible. + provider: "openai".to_string(), + endpoint: agent.endpoint.clone(), + model: agent.model.clone(), + api_key: agent.api_key.clone().unwrap_or_default(), + ..Default::default() + }, + ); + } + + // Only the API-class orchestrator has an endpoint of its own; the CLI + // class is a subprocess with no HTTP surface to borrow. + let orch = &self.orchestrator; + if matches!(orch.class(), Ok(OrchestratorClass::Api(_))) && !orch.model.is_empty() { + let name = if orch.name.is_empty() { + "orchestrator".to_string() + } else { + orch.name.clone() + }; + let mut backend = crate::planning::Backend { + name: name.clone(), + provider: orch.provider.clone(), + // Resolved, not raw: `provider = "lmstudio"` with no endpoint + // means localhost:1234, and the planning wire has no notion of + // an lmstudio default of its own. + endpoint: orch.endpoint_url(), + model: orch.model.clone(), + api_key: orch.api_key.clone(), + auth_token: orch.auth_token.clone(), + ..Default::default() + }; + if orch.max_context_tokens > 0 { + backend.max_context_tokens = orch.max_context_tokens; + } + if orch.max_tool_iterations > 0 { + backend.max_tool_iterations = orch.max_tool_iterations; + } + derived.entry(name).or_insert(backend); + } + + self.planning.derived = derived; + } +} + pub fn save(config: &Config) -> anyhow::Result<()> { let path = config_path().ok_or_else(|| anyhow::anyhow!("cannot determine config path"))?; if let Some(parent) = path.parent() { @@ -1402,6 +1610,64 @@ kind = "codex" let empty: Config = toml::from_str("").unwrap(); assert!(empty.sessions.aliases.is_empty()); } + #[test] + fn planning_backends_are_derived_from_agents_and_the_orchestrator() { + let cfg = parse( + r#" +[orchestrator] +name = "agent" +provider = "lmstudio" +model = "qwen3.6-27b" +max_context_tokens = 131072 + +[agents.qwen] +endpoint = "http://localhost:8080/v1" +model = "qwen3.6-8b" +"#, + ) + .unwrap(); + assert_eq!(cfg.planning.backend_names(), vec!["agent", "qwen"]); + let agent = cfg.planning.backend("agent").unwrap(); + // An lmstudio orchestrator with no explicit endpoint must not land on + // api.openai.com. + assert_eq!(agent.endpoint, "http://localhost:1234/v1"); + assert_eq!(agent.max_context_tokens, 131072); + assert_eq!(cfg.planning.backend("qwen").unwrap().provider, "openai"); + // The picker opens on one of them rather than on nothing. + assert!(cfg.planning.default_backend().is_some()); + } + + #[test] + fn explicit_planning_backends_shadow_derived_ones() { + let cfg = parse( + r#" +[orchestrator] +name = "agent" +provider = "lmstudio" +model = "from-orchestrator" + +[planning.backends.agent] +provider = "anthropic" +model = "from-planning" +"#, + ) + .unwrap(); + assert_eq!(cfg.planning.backend_names(), vec!["agent"]); + assert_eq!( + cfg.planning.backend("agent").unwrap().model, + "from-planning" + ); + } + + #[test] + fn derived_backends_are_not_written_back_to_disk() { + let cfg = parse("[agents.qwen]\nendpoint = \"http://x/v1\"\nmodel = \"m\"\n").unwrap(); + assert!(!cfg.planning.derived.is_empty()); + let round_tripped = parse(&toml::to_string_pretty(&cfg).unwrap()).unwrap(); + // Derived again from [agents.*], never persisted as [planning.backends.*]. + assert!(round_tripped.planning.backends.is_empty()); + } + #[test] fn agents_table_parses_local_llm_endpoints() { let toml = r#" diff --git a/src/events.rs b/src/events.rs index c1a6ae3..54bbfcf 100644 --- a/src/events.rs +++ b/src/events.rs @@ -69,6 +69,53 @@ pub enum AppEvent { OrchestratorContextMax { max: u64, }, + /// A planning thread's assistant reply landed and the thread was saved. + PlanningReply { + thread_id: String, + text: String, + backend: String, + model: String, + /// Largest request the turn built, in estimated tokens. The persisted + /// thread does not carry tool traffic, so this is the only place the + /// cost of a turn that read the codebase is visible. + peak_tokens: usize, + /// Set when the reply arrived but persisting it failed — the user + /// should hear about that now, not on reopen. + save_error: Option, + }, + /// Models the orchestrator's endpoint reports serving, for its picker. + OrchestratorModels { + models: Vec, + }, + /// Progress line for the planning pane (thinking, tool calls). + PlanningStatus { + thread_id: String, + status: String, + }, + /// A planning turn or commit failed. `draft` carries the user's unsent + /// text back so the pane can restore it for a retry on another backend. + PlanningFailed { + thread_id: String, + draft: String, + error: String, + /// True when the failure was a context-budget overflow, which offers + /// compact/fork rather than a plain retry. + overflow: bool, + }, + /// Model ids a planning backend's endpoint advertises. Cached in the pane + /// so the picker doesn't re-probe on every open. + PlanningModels { + backend: String, + models: Vec, + }, + /// A plan revision was distilled and written to linkshell's store. + PlanningCommitted { + thread_id: String, + path: String, + revision: usize, + /// Files that changed since the thread read them, at commit time. + stale: Vec, + }, /// Model ID parsed from the session's JSONL log (Claude or Codex) SessionModel { session_id: usize, diff --git a/src/keybindings.rs b/src/keybindings.rs index 452aa5a..99a06a9 100644 --- a/src/keybindings.rs +++ b/src/keybindings.rs @@ -29,6 +29,8 @@ pub enum Action { FocusPaneUp, FocusPaneDown, BroadcastToggle, + DockPlanning, + PlanningFullscreen, Detach, } @@ -75,11 +77,18 @@ fn default_keymap() -> Keymap { m.insert((alt, KeyCode::Char('o')), Action::FocusNextPane); m.insert((alt, KeyCode::Char('b')), Action::BroadcastToggle); m.insert((alt, KeyCode::Char('d')), Action::Detach); + m.insert((alt, KeyCode::Char('p')), Action::DockPlanning); m.insert((alt, KeyCode::Left), Action::PrevSession); m.insert((alt, KeyCode::Right), Action::NextSession); m.insert((alt, KeyCode::Tab), Action::NextSession); m.insert((alt, KeyCode::BackTab), Action::PrevSession); let alt_shift = KeyModifiers::ALT | KeyModifiers::SHIFT; + // Shifted alt-p: the same pane, taking the whole terminal. Registered + // under both modifier sets because whether SHIFT is reported alongside an + // already-uppercase char depends on the terminal's keyboard protocol — + // kitty-style reporting sets it, a plain ESC-prefixed 'P' does not. + m.insert((alt_shift, KeyCode::Char('P')), Action::PlanningFullscreen); + m.insert((alt, KeyCode::Char('P')), Action::PlanningFullscreen); m.insert((alt_shift, KeyCode::PageUp), Action::ScrollUpPage); m.insert((alt_shift, KeyCode::PageDown), Action::ScrollDownPage); m.insert((alt_shift, KeyCode::Left), Action::FocusPaneLeft); @@ -166,6 +175,8 @@ fn parse_action(s: &str) -> Option { "scroll_down_line" => Some(Action::ScrollDownLine), "toggle_chat" | "chat" => Some(Action::ToggleChat), "dock_chat" | "chat_dock" => Some(Action::DockChat), + "dock_planning" | "planning" => Some(Action::DockPlanning), + "planning_fullscreen" => Some(Action::PlanningFullscreen), "open_menu" => Some(Action::OpenMenu), "split_pane_right" | "split_right" => Some(Action::SplitPaneRight), "split_pane_down" | "split_down" => Some(Action::SplitPaneDown), diff --git a/src/main.rs b/src/main.rs index 270f26d..9794df5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod opencode_log; mod orchestrator; mod patterns; mod pipe; +mod planning; mod protocol; mod reattach; mod session; @@ -568,6 +569,41 @@ fn handle_event(app: &mut App, event: AppEvent) { AppEvent::ChatReply { from, text } => { app.handle_chat_reply(from, text); } + AppEvent::PlanningModels { backend, models } => { + app.handle_planning_models(backend, models); + } + AppEvent::PlanningStatus { thread_id, status } => { + app.handle_planning_status(thread_id, status); + } + AppEvent::OrchestratorModels { models } => { + app.handle_orchestrator_models(models); + } + AppEvent::PlanningReply { + thread_id, + text, + backend, + model, + peak_tokens, + save_error, + } => { + app.handle_planning_reply(thread_id, text, backend, model, peak_tokens, save_error); + } + AppEvent::PlanningFailed { + thread_id, + draft, + error, + overflow, + } => { + app.handle_planning_failed(thread_id, draft, error, overflow); + } + AppEvent::PlanningCommitted { + thread_id, + path, + revision, + stale, + } => { + app.handle_planning_committed(thread_id, path, revision, stale); + } AppEvent::Tick => app.handle_tick(), AppEvent::SessionBytes { session_id, data } => { // High-frequency path: full-screen TUIs stream bytes continuously. @@ -833,6 +869,14 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { app.dock_chat(None); } } + Action::PlanningFullscreen => app.toggle_planning_fullscreen(), + Action::DockPlanning => { + if app.planning_docked.is_some() { + app.undock_planning(); + } else { + app.dock_planning(None); + } + } Action::SplitPaneRight => app.split_focused(layout::SplitDir::Row), Action::SplitPaneDown => app.split_focused(layout::SplitDir::Col), Action::ClosePane => app.close_focused_pane(), @@ -862,6 +906,11 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { app.chat_key(key); return; } + // Docked planning pane focused → keys drive the planning pane + if app.planning_docked == Some(app.focused_pane) { + app.planning_key(key); + return; + } // Full-screen agent TUIs (claude, codex) ignore the terminal's // PageUp/PageDown sequences, so route those keys to linkshell's // captured scrollback — the same history the mouse wheel scrolls. @@ -1040,11 +1089,34 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { _ => {} }, + AppMode::OrchestratorModel { selected } => { + let len = app.orchestrator_models.len(); + match key.code { + KeyCode::Up => { + app.mode = AppMode::OrchestratorModel { + selected: selected.saturating_sub(1), + }; + } + KeyCode::Down => { + app.mode = AppMode::OrchestratorModel { + selected: (selected + 1).min(len.saturating_sub(1)), + }; + } + // A local server can load a different model while linkshell + // runs, so the list has to be refreshable in place. + KeyCode::Char('r') => app.refresh_orchestrator_models(), + KeyCode::Enter => app.orchestrator_model_picker_select(selected), + KeyCode::Esc => app.mode = AppMode::Normal, + _ => {} + } + app.needs_redraw = true; + } + AppMode::Help | AppMode::CommandResult => { app.mode = AppMode::Normal; // any key dismisses } - AppMode::Menu { .. } => { + AppMode::Menu { selected_sub, .. } => { if app.keymap.get(&(key.modifiers, key.code)) == Some(&Action::OpenMenu) { app.mode = AppMode::Normal; return; @@ -1052,37 +1124,26 @@ fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) { match key.code { KeyCode::Left => app.menu_move_top(-1), KeyCode::Right => app.menu_move_top(1), - KeyCode::Down => app.menu_open_submenu(), - KeyCode::Up => app.menu_close_submenu(), - KeyCode::Enter => app.execute_selected_menu_action(), - KeyCode::Esc => app.mode = AppMode::Normal, - KeyCode::Char(c) => match c.to_ascii_lowercase() { - 's' => { - app.mode = AppMode::Menu { - selected_top: 0, - selected_sub: Some(0), - } - } - 'v' => { - app.mode = AppMode::Menu { - selected_top: 1, - selected_sub: Some(0), - } - } - 'p' => { - app.mode = AppMode::Menu { - selected_top: 2, - selected_sub: Some(0), - } - } - 'h' => { - app.mode = AppMode::Menu { - selected_top: 3, - selected_sub: Some(0), - } + // The first Down drops into the section; subsequent ones walk + // it. Up walks back and pops out at the top row. + KeyCode::Down => { + if selected_sub.is_some() { + app.menu_move_sub(1) + } else { + app.menu_open_submenu() } - _ => {} + } + KeyCode::Up => match selected_sub { + Some(0) | None => app.menu_close_submenu(), + Some(_) => app.menu_move_sub(-1), }, + KeyCode::Enter => app.execute_selected_menu_action(), + KeyCode::Esc => app.mode = AppMode::Normal, + // Mnemonics come from the live section titles, so adding or + // reordering a section cannot silently steal another's letter. + KeyCode::Char(c) => { + app.menu_jump_to_mnemonic(c); + } _ => {} } } diff --git a/src/planning/distill.rs b/src/planning/distill.rs new file mode 100644 index 0000000..d267923 --- /dev/null +++ b/src/planning/distill.rs @@ -0,0 +1,298 @@ +//! Committing a thread: distilling a conversation into a usable plan. +//! +//! A planning thread is a conversation — digressions, corrections, abandoned +//! branches. What an implementation session needs is the conclusion. The +//! distiller is a separate one-shot call over the whole transcript that +//! produces that artifact. +//! +//! The distiller backend is chosen independently of the thread backend, and +//! that is the point: a thread can be built cheaply on a local model while +//! the distillation — the part that actually gets handed to an agent — gets +//! the frontier model. It is one call over an existing transcript, so it is +//! the cheapest place in the pipeline to spend good tokens. +//! +//! Plans are written into linkshell's own store, never into the workspace. +//! The workspace is read-only for the whole planning flow; a commit step that +//! wrote a file into the repository would punch a hole straight through that +//! guarantee, and would leave an artifact every project had to gitignore. +//! Exporting a plan into a repo is a separate, deliberate user action. + +use super::store::{self, PlanRevision, Role, Thread}; +use super::{Backend, TurnError, Wire}; +use crate::events::AppEvent; +use tokio::sync::mpsc; + +const DISTILL_PROMPT: &str = "\ +Below is a planning conversation between an engineer and a planning agent. \ +Distill it into a single implementation plan that a coding agent can execute \ +without having read the conversation. + +Rules: +- Include only what was actually decided. Drop digressions, abandoned \ +approaches, and anything left open — unless a decision explicitly depends on \ +it, in which case state it as an open question at the end. +- Where the conversation names real files, functions, or types, keep those \ +names exactly. They are the plan's anchors. +- Where the conversation gave a reason for a decision, keep the reason. An \ +agent that knows why a constraint exists will not quietly violate it. +- Do not invent detail that was not discussed. If the plan is thin in a \ +place, let it be thin. + +Structure the output as markdown: a short summary, then the concrete changes \ +in the order they should be made, then open questions if any remain. Output \ +only the plan itself."; + +/// Render a thread as a plain transcript for the distiller. +fn transcript(thread: &Thread) -> String { + let mut out = String::new(); + for m in &thread.messages { + let who = match m.role { + Role::User => "ENGINEER", + Role::Assistant => "PLANNER", + }; + out.push_str(&format!("--- {} ---\n{}\n\n", who, m.text.trim())); + } + out +} + +/// Run the distiller and write a new plan revision. +/// +/// Returns the revision written. The thread's `revisions` counter is bumped +/// so the next commit lands beside this one rather than on top of it — +/// revisions are diffable, and comparing two distillations is most of the +/// value of committing more than once. +pub async fn commit( + thread: &mut Thread, + distiller: &Backend, + client: &reqwest::Client, +) -> Result { + if thread.messages.is_empty() { + return Err(TurnError::Request(anyhow::anyhow!( + "nothing to distill: the thread is empty" + ))); + } + + let body_text = transcript(thread); + let prompt = format!("{}\n\n{}", DISTILL_PROMPT, body_text); + + // The distiller sees the whole transcript in one shot, so it is the most + // likely place to overflow a small local window — check before sending. + let estimate = prompt.len() / 4; + if distiller.max_context_tokens > 0 && estimate > distiller.max_context_tokens { + return Err(TurnError::ContextOverflow { + estimate, + limit: distiller.max_context_tokens, + backend: distiller.label(), + }); + } + + let text = match distiller.wire().map_err(TurnError::Request)? { + Wire::Anthropic => distill_anthropic(distiller, &prompt, client).await, + Wire::OpenAi => distill_openai(distiller, &prompt, client).await, + } + .map_err(TurnError::Request)?; + + if text.trim().is_empty() { + return Err(TurnError::Request(anyhow::anyhow!( + "distiller returned an empty plan" + ))); + } + + let revision = store::write_plan(thread, &text, distiller).map_err(TurnError::Request)?; + thread.revisions = revision.revision; + thread.updated = store::now_secs(); + store::save(thread).map_err(TurnError::Request)?; + Ok(revision) +} + +async fn distill_anthropic( + backend: &Backend, + prompt: &str, + client: &reqwest::Client, +) -> anyhow::Result { + let url = format!( + "{}/v1/messages", + backend.endpoint_url().trim_end_matches('/') + ); + let body = serde_json::json!({ + "model": backend.model, + "max_tokens": backend.max_tokens.max(4096), + "messages": [{"role": "user", "content": prompt}], + }); + let mut req = client + .post(&url) + .header("anthropic-version", "2023-06-01") + .json(&body) + .timeout(std::time::Duration::from_secs(600)); + if let Some(token) = backend.resolve_auth_token() { + req = req.bearer_auth(token); + } else if let Some(key) = backend.resolve_api_key() { + req = req.header("x-api-key", key); + } else { + anyhow::bail!("no Anthropic credentials for distiller {}", backend.name); + } + let resp: serde_json::Value = req.send().await?.json().await?; + if let Some(err) = resp.get("error").filter(|e| !e.is_null()) { + anyhow::bail!( + "api error: {}", + err["message"].as_str().unwrap_or("unknown") + ); + } + Ok(resp["content"] + .as_array() + .map(|blocks| { + blocks + .iter() + .filter(|b| b["type"] == "text") + .filter_map(|b| b["text"].as_str()) + .collect::>() + .join("\n") + }) + .unwrap_or_default()) +} + +async fn distill_openai( + backend: &Backend, + prompt: &str, + client: &reqwest::Client, +) -> anyhow::Result { + let url = crate::agent_llm::completions_url(&backend.endpoint_url()); + let body = serde_json::json!({ + "model": backend.model, + "messages": [{"role": "user", "content": prompt}], + }); + let mut req = client + .post(&url) + .json(&body) + .timeout(std::time::Duration::from_secs(600)); + if let Some(key) = backend.resolve_api_key() { + req = req.bearer_auth(key); + } + let resp: serde_json::Value = req.send().await?.json().await?; + if let Some(err) = resp.get("error").filter(|e| !e.is_null()) { + anyhow::bail!( + "api error: {}", + err["message"].as_str().unwrap_or("unknown") + ); + } + Ok(resp["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("") + .to_string()) +} + +/// Spawn a commit on a background task. +pub fn spawn_commit(mut thread: Thread, distiller: Backend, tx: mpsc::Sender) { + tokio::spawn(async move { + let client = reqwest::Client::new(); + let event = match commit(&mut thread, &distiller, &client).await { + Ok(rev) => AppEvent::PlanningCommitted { + thread_id: thread.id.clone(), + path: rev.path.to_string_lossy().to_string(), + revision: rev.revision, + stale: thread.stale_reads(), + }, + Err(e) => AppEvent::PlanningFailed { + thread_id: thread.id.clone(), + draft: String::new(), + error: format!("commit failed: {}", e), + overflow: matches!(e, TurnError::ContextOverflow { .. }), + }, + }; + let _ = tx.send(event).await; + }); +} + +/// Brief handed to an implementation session when a plan is opened as work. +/// +/// The contract is deliberately just a file path: an implementation session +/// runs as a subprocess under bwrap, and a single read-only bind mount of the +/// plan file is far simpler to arrange than serializing a thread into a +/// prompt. +pub fn session_brief(plan_path: &std::path::Path, stale: &[String]) -> String { + let mut brief = format!( + "Implement the plan at {}. Read it first, in full, before making any change.", + plan_path.display() + ); + if !stale.is_empty() { + brief.push_str(&format!( + "\n\nNote: these files changed after the plan was written, so parts of it may be \ + out of date — verify them against the current source before following it: {}.", + stale.join(", ") + )); + } + brief +} + +#[cfg(test)] +mod tests { + use super::super::store::Message; + use super::*; + use std::path::PathBuf; + + fn backend() -> Backend { + Backend { + name: "opus".to_string(), + provider: "anthropic".to_string(), + model: "claude-opus-4".to_string(), + max_context_tokens: 200_000, + ..Backend::default() + } + } + + fn thread() -> Thread { + let mut t = Thread::new("planning pane", PathBuf::from("/tmp/repo")); + t.messages.push(Message::user("Where does layout live?")); + t.messages.push(Message::assistant( + "src/layout.rs, a binary tree.", + &backend(), + )); + t + } + + #[test] + fn transcript_labels_both_speakers() { + let t = transcript(&thread()); + assert!(t.contains("--- ENGINEER ---")); + assert!(t.contains("--- PLANNER ---")); + assert!(t.contains("src/layout.rs")); + } + + #[tokio::test] + async fn empty_threads_are_rejected_before_any_request() { + let mut t = Thread::new("empty", PathBuf::from("/tmp/repo")); + let client = reqwest::Client::new(); + let err = commit(&mut t, &backend(), &client).await.unwrap_err(); + assert!(err.to_string().contains("nothing to distill")); + } + + #[tokio::test] + async fn oversized_transcripts_fail_the_budget_check_not_the_network() { + let mut t = thread(); + t.messages.push(Message::user("x".repeat(100_000))); + let small = Backend { + name: "local".to_string(), + provider: "lmstudio".to_string(), + endpoint: "http://127.0.0.1:1".to_string(), + model: "qwen".to_string(), + max_context_tokens: 1_000, + ..Backend::default() + }; + let client = reqwest::Client::new(); + let err = commit(&mut t, &small, &client).await.unwrap_err(); + assert!(matches!(err, TurnError::ContextOverflow { .. })); + assert!(err.to_string().contains("larger model")); + } + + #[test] + fn session_brief_is_a_path_plus_a_staleness_warning() { + let p = PathBuf::from("/home/u/.local/share/linkshell/planning/plans/t/001.md"); + let plain = session_brief(&p, &[]); + assert!(plain.contains("001.md")); + assert!(!plain.contains("out of date")); + + let warned = session_brief(&p, &["src/layout.rs".to_string()]); + assert!(warned.contains("src/layout.rs")); + assert!(warned.contains("out of date")); + } +} diff --git a/src/planning/mod.rs b/src/planning/mod.rs new file mode 100644 index 0000000..594bfb1 --- /dev/null +++ b/src/planning/mod.rs @@ -0,0 +1,848 @@ +//! Planning threads: a persistent, single-agent chat for designing work +//! before an implementation session starts. +//! +//! This is deliberately *not* the orchestrator chat. That pane is a log — +//! append-only, ephemeral, tail-oriented, you only care about the last few +//! lines. A planning thread is a document: you scroll back, you edit it, you +//! fork it, and its output is an artifact handed to an agent. Different +//! enough that sharing one widget would be a mistake. +//! +//! Three properties shape everything here: +//! +//! * **Read-only.** Planning grounds itself in the repository but never +//! mutates it. The tool surface in [`tools`] contains no write primitive, +//! so the guarantee is structural rather than prompted. +//! * **Runtime backend choice.** Anthropic, OpenAI, LM Studio and llama.cpp +//! are all selectable per turn, so a thread can be built cheaply on a local +//! model and distilled by a frontier one. Which model produced which turn +//! is recorded per message. +//! * **Global threads, pinned roots.** Threads are discoverable from +//! anywhere; the directory a thread was grounded in is a property of the +//! thread, never re-derived from wherever it was opened. +//! +//! Note that a planning agent is not a subprocess — it is an HTTP client +//! inside linkshell, with tool calls executed in this address space. There is +//! nothing for `bwrap` to contain, which is why the sandbox is a scoped tool +//! registry rather than a namespace. If planning ever needs to run a real +//! binary (`cargo check`, an LSP query), that changes and bwrap comes back. + +pub mod distill; +pub mod store; +pub mod tools; + +use std::path::Path; + +use crate::events::AppEvent; +use store::{Message, Role, Thread}; +use tokio::sync::mpsc; + +/// One selectable model endpoint, from `[planning.backends.NAME]`. +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(default)] +pub struct Backend { + /// Key in the config table; shown in the picker and stored per message. + #[serde(skip)] + pub name: String, + /// "anthropic", "openai", "lmstudio", or "llamacpp". + pub provider: String, + /// Base URL. Optional for anthropic/openai (env or default is used). + pub endpoint: String, + pub model: String, + pub api_key: String, + /// Anthropic bearer token, for gateways that require it. + pub auth_token: String, + pub max_tokens: u32, + /// Soft budget for the request, estimated at ~4 chars/token. A thread + /// that fits Anthropic's window will not fit LM Studio's, so this is a + /// per-backend number and switching model re-evaluates it. + pub max_context_tokens: usize, + /// Tool round-trips allowed in one turn. Grounding a plan in a codebase + /// is a grep-read-grep-read walk, and a question worth asking often needs + /// dozens of hops — this is a runaway-loop bound, not a budget the model + /// is expected to work within. Per-backend because a small local model + /// takes more hops to reach the same place than a frontier one. + pub max_tool_iterations: usize, +} + +impl Default for Backend { + fn default() -> Self { + Backend { + name: String::new(), + provider: "anthropic".to_string(), + endpoint: String::new(), + model: String::new(), + api_key: String::new(), + auth_token: String::new(), + max_tokens: 4096, + max_context_tokens: 60_000, + max_tool_iterations: 40, + } + } +} + +/// Which wire protocol a backend speaks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Wire { + Anthropic, + /// OpenAI-compatible `/v1/chat/completions`; also LM Studio and llama.cpp. + OpenAi, +} + +impl Backend { + pub fn wire(&self) -> anyhow::Result { + match self.provider.as_str() { + "anthropic" => Ok(Wire::Anthropic), + "openai" | "lmstudio" | "llamacpp" | "llama.cpp" | "ollama" | "vllm" => { + Ok(Wire::OpenAi) + } + other => anyhow::bail!( + "unknown planning provider {:?} (expected anthropic, openai, lmstudio, llamacpp)", + other + ), + } + } + + /// Base URL for requests, falling back to provider defaults and env vars. + pub fn endpoint_url(&self) -> String { + if !self.endpoint.is_empty() { + return self.endpoint.clone(); + } + match self.wire() { + Ok(Wire::Anthropic) => std::env::var("ANTHROPIC_BASE_URL") + .unwrap_or_else(|_| "https://api.anthropic.com".to_string()), + _ => std::env::var("OPENAI_BASE_URL") + .unwrap_or_else(|_| "https://api.openai.com".to_string()), + } + } + + pub fn resolve_api_key(&self) -> Option { + if !self.api_key.is_empty() { + return Some(self.api_key.clone()); + } + let var = match self.wire() { + Ok(Wire::Anthropic) => "ANTHROPIC_API_KEY", + _ => "OPENAI_API_KEY", + }; + std::env::var(var).ok().filter(|s| !s.is_empty()) + } + + pub fn resolve_auth_token(&self) -> Option { + if !self.auth_token.is_empty() { + return Some(self.auth_token.clone()); + } + std::env::var("ANTHROPIC_AUTH_TOKEN") + .ok() + .filter(|s| !s.is_empty()) + } + + /// Whether the picker should ask this endpoint what it serves. + /// + /// True for any OpenAI-wire endpoint that is not one of the hosted + /// catalogues. A self-hosted server's model list is live state — it serves + /// whatever is loaded — so asking beats writing a model id into config and + /// watching it go stale. The hosted APIs list hundreds of models and are + /// not going to surprise you, so they keep the configured id. + pub fn is_probeable(&self) -> bool { + if !matches!(self.wire(), Ok(Wire::OpenAi)) { + return false; + } + let url = self.endpoint_url(); + !url.is_empty() && !url.contains("api.openai.com") + } + + /// Tool round-trips allowed in one turn, with the 0 that a config + /// predating the field deserializes to treated as "unset". + pub fn tool_iterations(&self) -> usize { + if self.max_tool_iterations == 0 { + DEFAULT_TOOL_ITERATIONS + } else { + self.max_tool_iterations + } + } + + /// Label for the picker and status bar. + pub fn label(&self) -> String { + if self.model.is_empty() { + self.name.clone() + } else { + format!("{} · {}", self.name, self.model) + } + } +} + +/// Why a turn could not run or did not finish cleanly. +#[derive(Debug)] +pub enum TurnError { + /// The thread does not fit this backend's window. Surfaced rather than + /// silently compacted: in a planning thread the early turns are usually + /// the ones that matter, and quietly eating the design premises is worse + /// than asking. + ContextOverflow { + estimate: usize, + limit: usize, + backend: String, + }, + /// Transport or API failure. The caller keeps the draft and the thread so + /// the user can switch backend and retry the same message. + Request(anyhow::Error), +} + +impl std::fmt::Display for TurnError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TurnError::ContextOverflow { + estimate, + limit, + backend, + } => write!( + f, + "thread is ~{} tokens, over {}'s {} limit — compact, fork, or pick a larger model", + estimate, backend, limit + ), + TurnError::Request(e) => write!(f, "{}", e), + } + } +} + +impl std::error::Error for TurnError {} + +/// Rough token estimate at ~4 characters per token — the same heuristic the +/// orchestrator uses. Good enough to catch an overflow before the request. +pub fn estimate_tokens(messages: &[Message], pending: &str) -> usize { + let chars: usize = messages.iter().map(|m| m.text.len() + 16).sum::() + pending.len(); + chars / 4 +} + +/// Check the thread against a backend's window before sending. +pub fn check_budget(thread: &Thread, backend: &Backend, pending: &str) -> Result { + let estimate = estimate_tokens(&thread.messages, pending); + if backend.max_context_tokens > 0 && estimate > backend.max_context_tokens { + return Err(TurnError::ContextOverflow { + estimate, + limit: backend.max_context_tokens, + backend: backend.label(), + }); + } + Ok(estimate) +} + +/// Drop oldest turns until the thread fits, always keeping the most recent +/// exchange. Only called on explicit user confirmation after a +/// [`TurnError::ContextOverflow`] — never automatically. +pub fn compact(thread: &mut Thread, backend: &Backend, pending: &str) -> usize { + let limit = backend.max_context_tokens; + if limit == 0 { + return 0; + } + let mut dropped = 0; + while thread.messages.len() > 2 && estimate_tokens(&thread.messages, pending) > limit { + thread.messages.remove(0); + dropped += 1; + } + dropped +} + +const SYSTEM_PROMPT: &str = "\ +You are a planning partner inside linkshell, a terminal multiplexer for AI \ +coding agents. You are working through a design or plan with an engineer \ +before any code is written. + +You have read-only access to one directory tree. Use `grep` to locate things \ +and `read_file` to read them: a plan grounded in the actual source is worth \ +far more than one built from assumptions, so check the code rather than \ +guessing at its structure. You cannot modify anything, and no write, edit, or \ +shell tool exists for you to reach for. + +Be concrete. Name real files, functions, and types you have actually read. \ +When you are uncertain whether something exists, look rather than hedge. \ +Disagree when you think an approach is wrong, and say what you would do \ +instead. Prefer identifying the one decision that matters over enumerating \ +every option."; + +fn system_prompt(thread: &Thread) -> String { + let mut p = String::from(SYSTEM_PROMPT); + p.push_str(&format!( + "\n\nScope root (all paths resolve against it): {}", + thread.root.display() + )); + if !thread.reads.is_empty() { + let stale = thread.stale_reads(); + if !stale.is_empty() { + p.push_str(&format!( + "\n\nThese files changed on disk since this thread read them: {}. \ + Re-read any of them you intend to rely on.", + stale.join(", ") + )); + } + } + p +} + +/// Fallback when a backend carries no explicit `max_tool_iterations` (a +/// hand-written config predating the field deserializes it as 0). +const DEFAULT_TOOL_ITERATIONS: usize = 40; + +async fn status(tx: &mpsc::Sender, thread_id: &str, text: impl Into) { + let _ = tx + .send(AppEvent::PlanningStatus { + thread_id: thread_id.to_string(), + status: text.into(), + }) + .await; +} + +/// Run one planning turn to completion. +/// +/// Appends the user message and the assistant reply to `thread` and returns +/// the reply text. Tool results are consumed within the turn and never stored: +/// the thread records only *that* a file was read, so reopening it re-reads +/// current contents instead of carrying a stale snapshot forever. +pub async fn run_turn( + thread: &mut Thread, + backend: &Backend, + user_text: &str, + client: &reqwest::Client, + tx: &mpsc::Sender, +) -> Result<(String, usize), TurnError> { + check_budget(thread, backend, user_text)?; + let root = tools::canonical_root(&thread.root).map_err(TurnError::Request)?; + + let wire = backend.wire().map_err(TurnError::Request)?; + let reply = match wire { + Wire::Anthropic => run_anthropic(thread, backend, user_text, &root, client, tx).await, + Wire::OpenAi => run_openai(thread, backend, user_text, &root, client, tx).await, + }; + + match reply { + Ok(outcome) => { + for r in outcome.reads { + thread.record_read(r); + } + thread.messages.push(Message::user(user_text)); + thread + .messages + .push(Message::assistant(&outcome.text, backend)); + thread.updated = store::now_secs(); + Ok((outcome.text, outcome.peak_tokens)) + } + // On failure the thread is left exactly as it was, so the caller can + // keep the user's draft and retry it against a different backend. + Err(e) => Err(TurnError::Request(e)), + } +} + +/// What one turn actually sent, beyond the reply itself. +pub struct TurnOutcome { + pub text: String, + pub reads: Vec, + /// Largest request this turn built, in estimated tokens. + /// + /// The persisted thread is a poor proxy for what a turn costs: tool + /// results are consumed inside the turn and never stored, so a turn that + /// read 40k of source leaves a transcript of a few hundred tokens. The + /// meter has to report the peak the request actually reached, or it reads + /// as "nothing is being tracked" exactly when the window is filling up. + pub peak_tokens: usize, +} + +/// Estimated tokens in a request payload, at the same ~4 chars/token the rest +/// of the module uses. Serializing is the honest measure here: it counts tool +/// results and their JSON envelopes, which is where a code-reading turn's +/// context actually goes. +fn payload_tokens(system: &str, history: &[serde_json::Value]) -> usize { + let body: usize = history.iter().map(|m| m.to_string().len()).sum::(); + (system.len() + body) / 4 +} + +/// Build the wire history from persisted messages. Tool exchanges are absent +/// by construction, which is what keeps threads portable across a 32k local +/// model and a 200k hosted one. +fn base_history(thread: &Thread) -> Vec { + thread + .messages + .iter() + .map(|m| { + let role = match m.role { + Role::User => "user", + Role::Assistant => "assistant", + }; + serde_json::json!({"role": role, "content": m.text}) + }) + .collect() +} + +async fn run_anthropic( + thread: &Thread, + backend: &Backend, + user_text: &str, + root: &Path, + client: &reqwest::Client, + tx: &mpsc::Sender, +) -> anyhow::Result { + let url = format!( + "{}/v1/messages", + backend.endpoint_url().trim_end_matches('/') + ); + let mut history = base_history(thread); + history.push(serde_json::json!({"role": "user", "content": user_text})); + + let tool_defs = tools::anthropic_tools(); + let system = system_prompt(thread); + let mut reads: Vec = Vec::new(); + let mut peak_tokens = 0usize; + + let limit = backend.tool_iterations(); + for i in 0..=limit { + // The last pass runs without tools: hitting the ceiling should cost + // the user an answer written from what was already gathered, not the + // whole turn. Dropping the tools is what ends the loop — a model that + // cannot call one has to reply. + let last = i == limit; + status( + tx, + &thread.id, + if last { + "wrapping up (tool limit reached)".to_string() + } else { + format!("thinking ({}/{})", i + 1, limit) + }, + ) + .await; + let mut body = serde_json::json!({ + "model": backend.model, + "max_tokens": backend.max_tokens, + "system": system, + "tools": tool_defs, + "messages": history, + }); + if last { + if let Some(o) = body.as_object_mut() { + o.remove("tools"); + } + } + peak_tokens = peak_tokens.max(payload_tokens(&system, &history)); + let mut req = client + .post(&url) + .header("anthropic-version", "2023-06-01") + .json(&body) + .timeout(std::time::Duration::from_secs(600)); + if let Some(token) = backend.resolve_auth_token() { + req = req.bearer_auth(token); + } else if let Some(key) = backend.resolve_api_key() { + req = req.header("x-api-key", key); + } else { + anyhow::bail!( + "no Anthropic credentials for planning backend {} (set ANTHROPIC_API_KEY or \ + [planning.backends.{}].api_key)", + backend.name, + backend.name + ); + } + + let resp: serde_json::Value = req.send().await?.json().await?; + if let Some(err) = resp.get("error").filter(|e| !e.is_null()) { + anyhow::bail!("api error: {}", api_error_message(err)); + } + + let content = resp["content"].clone(); + history.push(serde_json::json!({"role": "assistant", "content": content})); + + let text = content + .as_array() + .map(|blocks| { + blocks + .iter() + .filter(|b| b["type"] == "text") + .filter_map(|b| b["text"].as_str()) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + + if resp["stop_reason"] != "tool_use" { + return Ok(TurnOutcome { + text, + reads, + peak_tokens, + }); + } + + let mut results: Vec = Vec::new(); + if let Some(blocks) = content.as_array() { + for b in blocks.iter().filter(|b| b["type"] == "tool_use") { + let name = b["name"].as_str().unwrap_or(""); + status( + tx, + &thread.id, + format!("{} {}", name, tool_hint(&b["input"])), + ) + .await; + let outcome = tools::exec(root, name, &b["input"]); + if let Some(r) = outcome.read { + reads.push(r); + } + results.push(serde_json::json!({ + "type": "tool_result", + "tool_use_id": b["id"], + "content": outcome.text, + })); + } + } + history.push(serde_json::json!({"role": "user", "content": results})); + } + + // Unreachable: the tool-less final pass cannot return stop_reason + // "tool_use", so the loop always returns from inside. + Ok(TurnOutcome { + text: String::new(), + reads, + peak_tokens, + }) +} + +async fn run_openai( + thread: &Thread, + backend: &Backend, + user_text: &str, + root: &Path, + client: &reqwest::Client, + tx: &mpsc::Sender, +) -> anyhow::Result { + let endpoint = backend.endpoint_url(); + if endpoint.is_empty() { + anyhow::bail!( + "no endpoint configured for planning backend {}", + backend.name + ); + } + let url = crate::agent_llm::completions_url(&endpoint); + let tool_defs = tools::openai_tools(); + + let mut history = base_history(thread); + history.push(serde_json::json!({"role": "user", "content": user_text})); + let mut reads: Vec = Vec::new(); + let mut peak_tokens = 0usize; + + let limit = backend.tool_iterations(); + for i in 0..=limit { + // See run_anthropic: the final pass drops the tools so the turn ends + // with an answer rather than a discarded walk of the codebase. + let last = i == limit; + status( + tx, + &thread.id, + if last { + "wrapping up (tool limit reached)".to_string() + } else { + format!("thinking ({}/{})", i + 1, limit) + }, + ) + .await; + let system = system_prompt(thread); + let mut messages = vec![serde_json::json!({"role": "system", "content": system})]; + messages.extend(history.iter().cloned()); + peak_tokens = peak_tokens.max(payload_tokens("", &messages)); + let mut body = serde_json::json!({ + "model": backend.model, + "messages": messages, + "tools": tool_defs, + "tool_choice": "auto", + }); + if last { + if let Some(o) = body.as_object_mut() { + o.remove("tools"); + o.remove("tool_choice"); + } + } + let mut req = client + .post(&url) + .json(&body) + .timeout(std::time::Duration::from_secs(600)); + if let Some(key) = backend.resolve_api_key() { + req = req.bearer_auth(key); + } + + let resp: serde_json::Value = req.send().await?.json().await?; + if let Some(err) = resp.get("error").filter(|e| !e.is_null()) { + anyhow::bail!("api error: {}", api_error_message(err)); + } + let message = resp["choices"][0]["message"].clone(); + if message.is_null() { + anyhow::bail!("no choices in response"); + } + history.push(message.clone()); + + let calls = message["tool_calls"] + .as_array() + .cloned() + .unwrap_or_default(); + if calls.is_empty() { + return Ok(TurnOutcome { + text: message["content"].as_str().unwrap_or("").to_string(), + reads, + peak_tokens, + }); + } + for call in &calls { + let name = call["function"]["name"].as_str().unwrap_or(""); + // `arguments` is a JSON-encoded string per the OpenAI spec. Local + // models get this wrong often enough that a parse failure must + // become a tool error the model can recover from, not a dead turn. + let args: serde_json::Value = call["function"]["arguments"] + .as_str() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(serde_json::json!({})); + status(tx, &thread.id, format!("{} {}", name, tool_hint(&args))).await; + let outcome = tools::exec(root, name, &args); + if let Some(r) = outcome.read { + reads.push(r); + } + history.push(serde_json::json!({ + "role": "tool", + "tool_call_id": call["id"], + "content": outcome.text, + })); + } + } + + // Unreachable: with no tools in the request there are no tool_calls to + // dispatch, so the final pass returns from inside the loop. + Ok(TurnOutcome { + text: String::new(), + reads, + peak_tokens, + }) +} + +/// Human-readable text out of an `error` field, whatever shape it arrived in. +/// +/// Not every server wraps it the same way: OpenAI and Anthropic send +/// `{"error": {"message": ...}}`, while LM Studio and llama.cpp often send a +/// bare string. Reaching only for `["message"]` turned the second case into +/// "api error: unknown" — a failure report that says nothing, on the one class +/// of backend whose failures you are most likely to have to debug. +fn api_error_message(err: &serde_json::Value) -> String { + if let Some(s) = err.as_str() { + return s.to_string(); + } + if let Some(s) = err["message"].as_str() { + return s.to_string(); + } + let raw = err.to_string(); + raw.chars().take(300).collect() +} + +/// Short description of a tool call for the status line. +fn tool_hint(args: &serde_json::Value) -> String { + args.get("path") + .or_else(|| args.get("pattern")) + .and_then(|v| v.as_str()) + .map(|s| s.chars().take(48).collect()) + .unwrap_or_default() +} + +/// Spawn a turn on a background task, delivering the result as an +/// [`AppEvent`]. Mirrors `agent_llm::spawn_chat_request` so the app loop +/// stays uniform. +pub fn spawn_turn( + mut thread: Thread, + backend: Backend, + user_text: String, + tx: mpsc::Sender, +) { + tokio::spawn(async move { + let client = reqwest::Client::new(); + let event = match run_turn(&mut thread, &backend, &user_text, &client, &tx).await { + Ok((text, peak_tokens)) => { + // Persist before notifying: if the write fails the user should + // hear about it now, not discover it on reopen. + let save_err = store::save(&thread).err().map(|e| e.to_string()); + AppEvent::PlanningReply { + thread_id: thread.id.clone(), + text, + peak_tokens, + backend: backend.name.clone(), + model: backend.model.clone(), + save_error: save_err, + } + } + Err(e) => AppEvent::PlanningFailed { + thread_id: thread.id.clone(), + // The draft rides back with the failure so the pane can restore + // it into the input box for a retry on another backend. + draft: user_text.clone(), + error: e.to_string(), + overflow: matches!(e, TurnError::ContextOverflow { .. }), + }, + }; + let _ = tx.send(event).await; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn backend(name: &str, provider: &str, ctx: usize) -> Backend { + Backend { + name: name.to_string(), + provider: provider.to_string(), + model: "m".to_string(), + max_context_tokens: ctx, + ..Backend::default() + } + } + + /// The persisted transcript is not a measure of what a turn cost: tool + /// results are consumed inside the turn, so a turn that read a lot of + /// source leaves almost no trace in `estimate_tokens`. + #[test] + fn tool_traffic_is_invisible_to_the_transcript_estimate() { + let mut t = Thread::new("t", PathBuf::from("/tmp")); + t.messages.push(Message::user("read the whole module")); + t.messages.push(Message::assistant( + "Done.", + &backend("local", "lmstudio", 0), + )); + let transcript = estimate_tokens(&t.messages, ""); + assert!(transcript < 100, "a short transcript, got {transcript}"); + + // The same turn's request, with one file's contents in a tool result. + let history = vec![ + serde_json::json!({"role": "user", "content": "read the whole module"}), + serde_json::json!({"role": "tool", "content": "x".repeat(40_000)}), + ]; + let payload = payload_tokens("system prompt", &history); + assert!( + payload > 9_000, + "the file contents dominate the request, got {payload}" + ); + } + + /// A local server's error is usually a bare string; reaching only for + /// ["message"] reported "unknown" and lost the only diagnostic there was. + #[test] + fn api_errors_are_readable_whatever_shape_they_arrive_in() { + use serde_json::json; + assert_eq!( + api_error_message(&json!({"message": "context length exceeded"})), + "context length exceeded" + ); + assert_eq!( + api_error_message(&json!("model does not support tools")), + "model does not support tools" + ); + // No recognized shape: show the payload rather than the word "unknown". + let odd = api_error_message(&json!({"code": 500, "detail": "boom"})); + assert!(odd.contains("boom"), "got {odd}"); + } + + /// Grounding a plan in a codebase is a grep-read-grep-read walk; ten hops + /// is a couple of files. The default has to be a runaway bound, not a + /// budget the model is expected to finish inside. + #[test] + fn the_tool_iteration_default_leaves_room_to_read_a_codebase() { + assert_eq!(Backend::default().tool_iterations(), 40); + } + + /// A config written before the field existed deserializes it as 0, which + /// would otherwise mean "no tool calls at all". + #[test] + fn a_zero_iteration_limit_means_unset_not_zero() { + let b: Backend = toml::from_str("provider = \"anthropic\"\nmodel = \"m\"\n").unwrap(); + assert_eq!(b.max_tool_iterations, 40, "serde(default) fills the field"); + let explicit_zero = Backend { + max_tool_iterations: 0, + ..Backend::default() + }; + assert_eq!(explicit_zero.tool_iterations(), DEFAULT_TOOL_ITERATIONS); + } + + #[test] + fn provider_names_map_to_the_right_wire_protocol() { + assert_eq!( + backend("a", "anthropic", 0).wire().unwrap(), + Wire::Anthropic + ); + for p in ["openai", "lmstudio", "llamacpp", "ollama", "vllm"] { + assert_eq!(backend("x", p, 0).wire().unwrap(), Wire::OpenAi, "{}", p); + } + assert!(backend("x", "nonsense", 0).wire().is_err()); + } + + #[test] + fn overflow_is_reported_rather_than_silently_compacted() { + let mut t = Thread::new("t", PathBuf::from("/tmp")); + for _ in 0..40 { + t.messages.push(Message::user("x".repeat(1000))); + } + let small = backend("local", "lmstudio", 1_000); + let err = check_budget(&t, &small, "next").unwrap_err(); + match err { + TurnError::ContextOverflow { limit, backend, .. } => { + assert_eq!(limit, 1_000); + assert!(backend.contains("local")); + } + other => panic!("expected overflow, got {:?}", other), + } + // The same thread is fine on a larger window — switching model + // re-evaluates the budget rather than carrying a fixed verdict. + let big = backend("opus", "anthropic", 200_000); + assert!(check_budget(&t, &big, "next").is_ok()); + // And nothing was dropped as a side effect of checking. + assert_eq!(t.messages.len(), 40); + } + + #[test] + fn compact_only_runs_when_asked_and_keeps_the_latest_exchange() { + let mut t = Thread::new("t", PathBuf::from("/tmp")); + for i in 0..20 { + t.messages + .push(Message::user(format!("{}{}", i, "x".repeat(500)))); + } + let b = backend("local", "lmstudio", 1_000); + let dropped = compact(&mut t, &b, ""); + assert!(dropped > 0); + assert!(t.messages.len() >= 2, "never compacts below one exchange"); + assert!( + t.messages.last().unwrap().text.starts_with("19"), + "the newest turn survives" + ); + } + + #[test] + fn endpoint_defaults_by_provider_and_config_wins() { + let mut b = backend("x", "anthropic", 0); + assert!(b.endpoint_url().contains("anthropic.com")); + b.endpoint = "http://localhost:1234".to_string(); + assert_eq!(b.endpoint_url(), "http://localhost:1234"); + } + + #[test] + fn history_carries_no_tool_traffic() { + let mut t = Thread::new("t", PathBuf::from("/tmp")); + t.messages.push(Message::user("read src/lib.rs")); + t.messages.push(Message::assistant( + "It defines two functions.", + &backend("local", "lmstudio", 0), + )); + let h = base_history(&t); + assert_eq!(h.len(), 2); + assert!(h.iter().all(|m| m["role"] != "tool")); + assert_eq!(h[1]["content"], "It defines two functions."); + } + + #[test] + fn system_prompt_pins_the_root_and_flags_stale_grounding() { + let mut t = Thread::new("t", PathBuf::from("/tmp/repo")); + assert!(system_prompt(&t).contains("/tmp/repo")); + t.record_read(tools::ReadRecord { + rel: "vanished.rs".to_string(), + hash: 7, + mtime: None, + }); + let p = system_prompt(&t); + assert!(p.contains("vanished.rs"), "stale reads are surfaced: {}", p); + } +} diff --git a/src/planning/store.rs b/src/planning/store.rs new file mode 100644 index 0000000..81a78dc --- /dev/null +++ b/src/planning/store.rs @@ -0,0 +1,795 @@ +//! Persistence for planning threads and committed plans. +//! +//! Threads are global — discoverable from anywhere, listed in one place — +//! while the directory a thread was grounded in is a *property* of the +//! thread, recorded once and reused on reopen. A thread must never silently +//! re-ground against whatever directory it happened to be opened from; that +//! would quietly invalidate every file citation in it. +//! +//! Storage is markdown plus a sidecar JSON: +//! +//! ```text +//! ~/.local/share/linkshell/planning/ +//! threads/.md conversation body — readable, greppable, editable +//! threads/.json metadata: per-message provider/model, file reads +//! plans//001.md committed plan revisions (never overwritten) +//! plans//001.json provenance for each revision +//! ``` +//! +//! The markdown is the source of truth for message *text*, so fixing a bad +//! turn by editing the file in $EDITOR works. The JSON is the source of truth +//! for metadata, which has no natural home in markdown — per-message +//! front-matter gets ugly fast, and HTML comments make the readable file less +//! readable. When the two disagree (a hand edit added or removed a section), +//! the markdown wins and the missing metadata degrades to "unknown" rather +//! than the load failing. +//! +//! Tool results are deliberately *not* persisted. A thread records that a +//! file was read, with its hash and mtime, and re-materializes contents on +//! demand for the live request. That keeps threads small across backends with +//! wildly different context windows, and means a reopened thread reads the +//! current file rather than a stale snapshot. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::tools::ReadRecord; + +/// Who produced a message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + User, + Assistant, +} + +impl Role { + fn as_str(self) -> &'static str { + match self { + Role::User => "user", + Role::Assistant => "assistant", + } + } + + fn parse(s: &str) -> Option { + match s.trim() { + "user" => Some(Role::User), + "assistant" => Some(Role::Assistant), + _ => None, + } + } +} + +/// One turn in a planning thread. +/// +/// `backend`/`model` are recorded per message, not per thread: switching from +/// a local 27B to a frontier model mid-thread is expected, and you need to +/// see where the seam is to judge whether earlier turns deserve a re-run. +#[derive(Debug, Clone)] +pub struct Message { + pub role: Role, + pub text: String, + /// Configured backend name (`[planning.backends]` key) that produced this + /// message. Empty for user turns and for hand-edited additions. + pub backend: String, + /// Provider family: "anthropic", "openai", "lmstudio", "llamacpp". + pub provider: String, + /// Concrete model id as sent to the endpoint. + pub model: String, + /// Unix seconds. + pub at: u64, +} + +impl Message { + pub fn user(text: impl Into) -> Message { + Message { + role: Role::User, + text: text.into(), + backend: String::new(), + provider: String::new(), + model: String::new(), + at: now_secs(), + } + } + + pub fn assistant(text: impl Into, backend: &super::Backend) -> Message { + Message { + role: Role::Assistant, + text: text.into(), + backend: backend.name.clone(), + provider: backend.provider.clone(), + model: backend.model.clone(), + at: now_secs(), + } + } + + /// Short "who said this" label for the transcript gutter. + pub fn attribution(&self) -> String { + match self.role { + Role::User => "you".to_string(), + Role::Assistant => { + if self.model.is_empty() { + self.backend.clone() + } else { + format!("{} · {}", self.backend, self.model) + } + } + } + } +} + +/// A planning conversation. +#[derive(Debug, Clone)] +pub struct Thread { + pub id: String, + pub title: String, + /// Canonical scope root. Pinned at creation; tools resolve against it. + pub root: PathBuf, + pub created: u64, + pub updated: u64, + pub messages: Vec, + /// Every file the thread has read, latest record per path. + pub reads: HashMap, + /// Number of plan revisions committed from this thread. + pub revisions: usize, + /// Decisions pinned out of the conversation, newest last. What you scroll + /// back for is usually "what did we decide about X", and a short list is + /// cheaper to scan than the transcript. + pub decisions: Vec, +} + +impl Thread { + pub fn new(title: &str, root: PathBuf) -> Thread { + let at = now_secs(); + Thread { + id: new_id(), + title: if title.trim().is_empty() { + "untitled".to_string() + } else { + title.trim().to_string() + }, + root, + created: at, + updated: at, + messages: Vec::new(), + reads: HashMap::new(), + revisions: 0, + decisions: Vec::new(), + } + } + + /// Files whose content has changed since the thread read them. + /// + /// A plan grounded in a file read three days ago may be grounded in + /// fiction. This is what makes handoff to an implementation session + /// trustworthy: you can tell whether the brief still describes the repo. + pub fn stale_reads(&self) -> Vec { + let mut stale: Vec = self + .reads + .iter() + .filter(|(rel, rec)| { + let path = self.root.join(rel); + match fs::read(&path) { + Ok(bytes) => super::tools::content_hash(&bytes) != rec.hash, + // A file that vanished is at least as stale as one that changed. + Err(_) => true, + } + }) + .map(|(rel, _)| rel.clone()) + .collect(); + stale.sort(); + stale + } + + pub fn record_read(&mut self, rec: ReadRecord) { + self.reads.insert(rec.rel.clone(), rec); + } +} + +// ── Paths ───────────────────────────────────────────────────────────────── + +/// Base directory for planning state: `$XDG_DATA_HOME/linkshell/planning`, +/// falling back to `~/.local/share/linkshell/planning`. +pub fn base_dir() -> anyhow::Result { + let data = std::env::var("XDG_DATA_HOME") + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join(".local").join("share")) + }) + .ok_or_else(|| anyhow::anyhow!("neither XDG_DATA_HOME nor HOME is set"))?; + Ok(data.join("linkshell").join("planning")) +} + +fn threads_dir() -> anyhow::Result { + Ok(base_dir()?.join("threads")) +} + +/// Directory holding committed plan revisions for a thread. +pub fn plans_dir(thread_id: &str) -> anyhow::Result { + Ok(base_dir()?.join("plans").join(thread_id)) +} + +fn new_id() -> String { + // Timestamp prefix keeps the threads directory sorted by age; the + // nanosecond tail disambiguates threads created in the same second. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + format!( + "{:010}-{:06}", + now.as_secs(), + now.subsec_nanos() % 1_000_000 + ) +} + +pub fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +// ── Serialization ───────────────────────────────────────────────────────── + +/// Markdown section header separating messages. Chosen so that a message body +/// containing its own `## ` headings does not confuse the parser: only a +/// heading matching this exact shape starts a new message. +const ROLE_PREFIX: &str = "## "; + +fn render_markdown(thread: &Thread) -> String { + let mut out = String::new(); + out.push_str(&format!("# {}\n\n", thread.title)); + out.push_str(&format!( + "\n", + thread.id + )); + out.push_str(&format!("\n\n", thread.root.display())); + for m in &thread.messages { + out.push_str(&format!("{}{}\n\n", ROLE_PREFIX, m.role.as_str())); + out.push_str(m.text.trim_end()); + out.push_str("\n\n"); + } + out +} + +/// Parse the markdown body back into (title, message role/text pairs). +fn parse_markdown(text: &str) -> (String, Vec<(Role, String)>) { + let mut title = String::new(); + let mut messages: Vec<(Role, String)> = Vec::new(); + let mut current: Option<(Role, String)> = None; + + for line in text.lines() { + if title.is_empty() && line.starts_with("# ") && current.is_none() { + title = line[2..].trim().to_string(); + continue; + } + // A heading only starts a new message if it names a known role; + // `## Design notes` inside an assistant turn stays part of the body. + if let Some(rest) = line.strip_prefix(ROLE_PREFIX) { + if let Some(role) = Role::parse(rest) { + if let Some((r, body)) = current.take() { + messages.push((r, body.trim().to_string())); + } + current = Some((role, String::new())); + continue; + } + } + if let Some((_, body)) = current.as_mut() { + body.push_str(line); + body.push('\n'); + } + } + if let Some((r, body)) = current.take() { + messages.push((r, body.trim().to_string())); + } + (title, messages) +} + +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct MessageMeta { + #[serde(default)] + backend: String, + #[serde(default)] + provider: String, + #[serde(default)] + model: String, + #[serde(default)] + at: u64, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct ReadMeta { + hash: u64, + #[serde(default)] + mtime: Option, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct Sidecar { + id: String, + root: String, + created: u64, + updated: u64, + #[serde(default)] + revisions: usize, + #[serde(default)] + decisions: Vec, + #[serde(default)] + messages: Vec, + #[serde(default)] + reads: HashMap, +} + +/// Write a thread to disk. Both files are written via a temp-and-rename so a +/// crash mid-write cannot leave a half-parsed thread behind. +pub fn save(thread: &Thread) -> anyhow::Result<()> { + let dir = threads_dir()?; + fs::create_dir_all(&dir)?; + + let sidecar = Sidecar { + id: thread.id.clone(), + root: thread.root.to_string_lossy().to_string(), + created: thread.created, + updated: thread.updated, + revisions: thread.revisions, + decisions: thread.decisions.clone(), + messages: thread + .messages + .iter() + .map(|m| MessageMeta { + backend: m.backend.clone(), + provider: m.provider.clone(), + model: m.model.clone(), + at: m.at, + }) + .collect(), + reads: thread + .reads + .iter() + .map(|(k, v)| { + ( + k.clone(), + ReadMeta { + hash: v.hash, + mtime: v.mtime, + }, + ) + }) + .collect(), + }; + + write_atomic( + &dir.join(format!("{}.md", thread.id)), + &render_markdown(thread), + )?; + write_atomic( + &dir.join(format!("{}.json", thread.id)), + &serde_json::to_string_pretty(&sidecar)?, + )?; + Ok(()) +} + +fn write_atomic(path: &Path, content: &str) -> anyhow::Result<()> { + let tmp = path.with_extension(format!( + "{}.tmp", + path.extension() + .map(|e| e.to_string_lossy().to_string()) + .unwrap_or_default() + )); + fs::write(&tmp, content)?; + fs::rename(&tmp, path)?; + Ok(()) +} + +/// Load one thread by id. +pub fn load(id: &str) -> anyhow::Result { + let dir = threads_dir()?; + let md = fs::read_to_string(dir.join(format!("{}.md", id))) + .map_err(|e| anyhow::anyhow!("thread {}: {}", id, e))?; + let (title, parsed) = parse_markdown(&md); + + let sidecar: Option = fs::read_to_string(dir.join(format!("{}.json", id))) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()); + + // Markdown wins on text and count; metadata is zipped in positionally and + // degrades to empty when a hand edit changed the message count. + let metas = sidecar + .as_ref() + .map(|s| s.messages.as_slice()) + .unwrap_or(&[]); + let aligned = metas.len() == parsed.len(); + let messages = parsed + .into_iter() + .enumerate() + .map(|(i, (role, text))| { + let m = if aligned { Some(&metas[i]) } else { None }; + Message { + role, + text, + backend: m.map(|m| m.backend.clone()).unwrap_or_default(), + provider: m.map(|m| m.provider.clone()).unwrap_or_default(), + model: m.map(|m| m.model.clone()).unwrap_or_default(), + at: m.map(|m| m.at).unwrap_or(0), + } + }) + .collect(); + + let root = sidecar + .as_ref() + .map(|s| PathBuf::from(&s.root)) + .or_else(|| { + // Fall back to the root comment in the markdown so a thread whose + // sidecar was lost still knows what it was grounded in. + md.lines() + .find_map(|l| l.strip_prefix("")) + .map(PathBuf::from) + }) + .ok_or_else(|| anyhow::anyhow!("thread {} has no recorded scope root", id))?; + + Ok(Thread { + id: id.to_string(), + title, + root, + created: sidecar.as_ref().map(|s| s.created).unwrap_or(0), + updated: sidecar.as_ref().map(|s| s.updated).unwrap_or(0), + messages, + reads: sidecar + .as_ref() + .map(|s| { + s.reads + .iter() + .map(|(rel, r)| { + ( + rel.clone(), + ReadRecord { + rel: rel.clone(), + hash: r.hash, + mtime: r.mtime, + }, + ) + }) + .collect() + }) + .unwrap_or_default(), + revisions: sidecar.as_ref().map(|s| s.revisions).unwrap_or(0), + decisions: sidecar + .as_ref() + .map(|s| s.decisions.clone()) + .unwrap_or_default(), + }) +} + +/// Summary row for the thread list in the left sidebar. +#[derive(Debug, Clone)] +pub struct ThreadSummary { + pub id: String, + pub title: String, + pub root: PathBuf, + pub updated: u64, + pub messages: usize, +} + +/// All threads, most recently updated first. +pub fn list() -> anyhow::Result> { + let dir = threads_dir()?; + if !dir.exists() { + return Ok(Vec::new()); + } + let mut out: Vec = Vec::new(); + for entry in fs::read_dir(&dir)?.flatten() { + let path = entry.path(); + if path.extension().map(|e| e != "md").unwrap_or(true) { + continue; + } + let id = match path.file_stem().map(|s| s.to_string_lossy().to_string()) { + Some(s) => s, + None => continue, + }; + // A malformed thread should not take the whole list down with it. + if let Ok(t) = load(&id) { + out.push(ThreadSummary { + id: t.id, + title: t.title, + root: t.root, + updated: t.updated, + messages: t.messages.len(), + }); + } + } + out.sort_by_key(|s| std::cmp::Reverse(s.updated)); + Ok(out) +} + +/// Delete a thread and its sidecar. Committed plans are left in place — they +/// are the durable artifact and outlive the conversation that produced them. +pub fn delete(id: &str) -> anyhow::Result<()> { + let dir = threads_dir()?; + let _ = fs::remove_file(dir.join(format!("{}.json", id))); + fs::remove_file(dir.join(format!("{}.md", id)))?; + Ok(()) +} + +// ── Committed plans ─────────────────────────────────────────────────────── + +/// A distilled plan written to linkshell's own store. +#[derive(Debug, Clone)] +pub struct PlanRevision { + pub path: PathBuf, + pub revision: usize, +} + +/// Write a new plan revision. Revisions are never overwritten: plans get +/// revised, and being able to diff two distillations is most of the value of +/// committing at all. +pub fn write_plan( + thread: &Thread, + body: &str, + distiller: &super::Backend, +) -> anyhow::Result { + let dir = plans_dir(&thread.id)?; + fs::create_dir_all(&dir)?; + let revision = thread.revisions + 1; + let path = dir.join(format!("{:03}.md", revision)); + + let mut out = String::new(); + out.push_str(&format!("# {}\n\n", thread.title)); + out.push_str(&format!( + "\n", + thread.id, revision + )); + out.push_str(&format!("\n", thread.root.display())); + out.push_str(&format!( + "\n\n", + distiller.model, distiller.name + )); + out.push_str(body.trim()); + out.push('\n'); + write_atomic(&path, &out)?; + + let meta = serde_json::json!({ + "thread": thread.id, + "revision": revision, + "root": thread.root.to_string_lossy(), + "distilled_by": { + "backend": distiller.name, + "provider": distiller.provider, + "model": distiller.model, + }, + "at": now_secs(), + "source_messages": thread.messages.len(), + "grounded_in": thread.reads.keys().collect::>(), + "stale_at_commit": thread.stale_reads(), + }); + write_atomic( + &dir.join(format!("{:03}.json", revision)), + &serde_json::to_string_pretty(&meta)?, + )?; + + Ok(PlanRevision { path, revision }) +} + +/// Path of the most recent committed plan for a thread, if any. +pub fn latest_plan(thread_id: &str) -> Option { + let dir = plans_dir(thread_id).ok()?; + let mut revs: Vec = fs::read_dir(&dir) + .ok()? + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().map(|e| e == "md").unwrap_or(false)) + .collect(); + revs.sort(); + revs.pop() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Point the store at a scratch directory for the duration of a test. + fn with_temp_home(f: impl FnOnce() -> T) -> T { + // Tests touching process env must not interleave. + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!( + "linkshell-store-test-{}-{}", + std::process::id(), + now_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let prev = std::env::var("XDG_DATA_HOME").ok(); + std::env::set_var("XDG_DATA_HOME", &dir); + let out = f(); + match prev { + Some(p) => std::env::set_var("XDG_DATA_HOME", p), + None => std::env::remove_var("XDG_DATA_HOME"), + } + let _ = fs::remove_dir_all(&dir); + out + } + + fn now_nanos() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + } + + fn backend(name: &str, model: &str) -> super::super::Backend { + super::super::Backend { + name: name.to_string(), + provider: "anthropic".to_string(), + model: model.to_string(), + ..Default::default() + } + } + + fn sample() -> Thread { + let mut t = Thread::new("Planning pane", PathBuf::from("/tmp/repo")); + t.messages.push(Message::user("How does layout work?")); + t.messages.push(Message::assistant( + "It is a binary tree.", + &backend("local", "qwen3-27b"), + )); + t.messages.push(Message::user("And panes?")); + t.messages.push(Message::assistant( + "Parallel vectors indexed by leaf order.", + &backend("opus", "claude-opus-4"), + )); + t + } + + #[test] + fn round_trips_through_markdown_and_sidecar() { + with_temp_home(|| { + let t = sample(); + save(&t).unwrap(); + let back = load(&t.id).unwrap(); + assert_eq!(back.title, "Planning pane"); + assert_eq!(back.root, PathBuf::from("/tmp/repo")); + assert_eq!(back.messages.len(), 4); + assert_eq!(back.messages[0].text, "How does layout work?"); + assert_eq!( + back.messages[3].text, + "Parallel vectors indexed by leaf order." + ); + }); + } + + #[test] + fn per_message_model_survives_a_mid_thread_switch() { + with_temp_home(|| { + let t = sample(); + save(&t).unwrap(); + let back = load(&t.id).unwrap(); + assert_eq!(back.messages[1].model, "qwen3-27b"); + assert_eq!(back.messages[3].model, "claude-opus-4"); + assert!(back.messages[3].attribution().contains("claude-opus-4")); + }); + } + + #[test] + fn hand_edited_markdown_wins_over_stale_metadata() { + with_temp_home(|| { + let t = sample(); + save(&t).unwrap(); + // Simulate fixing a turn in $EDITOR, adding a message. + let dir = threads_dir().unwrap(); + let p = dir.join(format!("{}.md", t.id)); + let mut md = fs::read_to_string(&p).unwrap(); + md.push_str("## user\n\nOne more question.\n\n"); + fs::write(&p, md).unwrap(); + + let back = load(&t.id).unwrap(); + assert_eq!(back.messages.len(), 5, "the edit is honored"); + assert_eq!(back.messages[4].text, "One more question."); + // Counts no longer align, so metadata degrades rather than lying. + assert_eq!(back.messages[1].model, ""); + }); + } + + #[test] + fn message_bodies_may_contain_their_own_headings() { + with_temp_home(|| { + let mut t = Thread::new("headings", PathBuf::from("/tmp/repo")); + t.messages.push(Message::assistant( + "## Design notes\n\nSome detail.\n\n## Risks\n\nMore detail.", + &backend("local", "qwen"), + )); + save(&t).unwrap(); + let back = load(&t.id).unwrap(); + assert_eq!( + back.messages.len(), + 1, + "inner headings must not split turns" + ); + assert!(back.messages[0].text.contains("## Risks")); + }); + } + + #[test] + fn thread_list_is_newest_first_and_survives_a_corrupt_entry() { + with_temp_home(|| { + let mut a = sample(); + a.title = "older".into(); + a.updated = 1_000; + save(&a).unwrap(); + let mut b = sample(); + b.title = "newer".into(); + b.updated = 2_000; + save(&b).unwrap(); + // A junk file in the directory must not take the list down. + fs::write(threads_dir().unwrap().join("garbage.md"), "not a thread").unwrap(); + + let list = list().unwrap(); + let titles: Vec<&str> = list.iter().map(|s| s.title.as_str()).collect(); + assert_eq!(&titles[..2], &["newer", "older"]); + }); + } + + #[test] + fn stale_reads_flags_changed_and_missing_files() { + with_temp_home(|| { + let root = std::env::temp_dir().join(format!("ls-stale-{}", now_nanos())); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("a.rs"), "original").unwrap(); + fs::write(root.join("b.rs"), "stable").unwrap(); + let root = root.canonicalize().unwrap(); + + let mut t = Thread::new("stale", root.clone()); + for name in ["a.rs", "b.rs"] { + let bytes = fs::read(root.join(name)).unwrap(); + t.record_read(ReadRecord { + rel: name.to_string(), + hash: super::super::tools::content_hash(&bytes), + mtime: None, + }); + } + t.record_read(ReadRecord { + rel: "gone.rs".to_string(), + hash: 42, + mtime: None, + }); + assert_eq!(t.stale_reads(), vec!["gone.rs".to_string()]); + + fs::write(root.join("a.rs"), "changed under the plan").unwrap(); + assert_eq!( + t.stale_reads(), + vec!["a.rs".to_string(), "gone.rs".to_string()] + ); + }); + } + + #[test] + fn plan_revisions_accumulate_instead_of_clobbering() { + with_temp_home(|| { + let mut t = sample(); + let d = backend("opus", "claude-opus-4"); + let r1 = write_plan(&t, "First cut.", &d).unwrap(); + assert_eq!(r1.revision, 1); + t.revisions = 1; + let r2 = write_plan(&t, "Revised after review.", &d).unwrap(); + assert_eq!(r2.revision, 2); + assert!(r1.path.exists() && r2.path.exists()); + assert!(fs::read_to_string(&r1.path).unwrap().contains("First cut.")); + assert_eq!(latest_plan(&t.id).unwrap(), r2.path); + }); + } + + #[test] + fn deleting_a_thread_keeps_its_committed_plans() { + with_temp_home(|| { + let t = sample(); + save(&t).unwrap(); + let plan = write_plan(&t, "durable", &backend("opus", "claude-opus-4")).unwrap(); + delete(&t.id).unwrap(); + assert!(load(&t.id).is_err()); + assert!(plan.path.exists(), "plans outlive the conversation"); + }); + } +} diff --git a/src/planning/tools.rs b/src/planning/tools.rs new file mode 100644 index 0000000..e1f2250 --- /dev/null +++ b/src/planning/tools.rs @@ -0,0 +1,627 @@ +//! Read-only filesystem tools for planning threads. +//! +//! Planning agents ground their plans in the repository, so they need to read +//! it — but a planning thread must never mutate the workspace. That guarantee +//! is structural rather than prompted: this module defines the *entire* tool +//! surface a planning thread has, and there is no write, edit, or shell +//! primitive in it. A model that asks for one gets "no such tool". +//! +//! `bash` is deliberately absent. It is a write primitive wearing a search +//! costume: `rg pattern` and `sh -c 'rm -rf'` travel the same channel. `grep` +//! here is a real implementation over `walkdir`-style recursion with a +//! compiled `regex`, never a shell string. +//! +//! Every path the model supplies is resolved against the thread's pinned +//! scope root and rejected if it escapes — including via `..`, an absolute +//! path, or a symlink pointing outside. Without that check a plan asking +//! about "your config" walks straight into `~/.ssh` or the linkshell config +//! holding API keys. + +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +/// Largest single file read returned to the model, in bytes. Reads past this +/// are truncated with a marker; the model can ask for a later offset. +const MAX_READ_BYTES: usize = 64 * 1024; +/// Largest file `grep` will scan. Bigger files are almost always data. +const MAX_GREP_FILE_BYTES: u64 = 4 * 1024 * 1024; +/// Cap on grep hits returned in one call. +const MAX_GREP_HITS: usize = 200; +/// Cap on entries returned by `list_dir`. +const MAX_LIST_ENTRIES: usize = 500; +/// Directory names never descended into during recursive search. +const SKIP_DIRS: &[&str] = &[ + ".git", + "target", + "node_modules", + ".venv", + "venv", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + "dist", + "build", + ".next", + ".cargo", +]; + +/// A resolved, in-scope path plus the identity of what was read, so the +/// thread can tell later whether its grounding has gone stale. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadRecord { + /// Path relative to the scope root, as stored in the thread. + pub rel: String, + /// Content hash at read time (FNV-1a, 64-bit — this is a change detector, + /// not a security primitive). + pub hash: u64, + /// Modification time as seconds since the Unix epoch, when available. + pub mtime: Option, +} + +/// Cheap, dependency-free content hash. Used only to detect that a file +/// changed under a plan; collisions here cost a spurious "unchanged", not a +/// security boundary. +pub fn content_hash(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in bytes { + h ^= *b as u64; + h = h.wrapping_mul(0x1000_0000_01b3); + } + h +} + +fn mtime_secs(path: &Path) -> Option { + fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) +} + +/// Lexically normalize a path, resolving `.` and `..` without touching the +/// filesystem. Used *before* canonicalization so that a path escaping the +/// root is rejected even when the target does not exist (canonicalize fails +/// on missing files, which would otherwise leave a hole). +fn lexical_normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +/// Resolve a model-supplied path against the scope root. +/// +/// Rejects absolute paths, `..` escapes, and symlinks whose target lands +/// outside the root. `root` must already be canonical (see +/// [`canonical_root`]). +pub fn resolve_in_root(root: &Path, input: &str) -> anyhow::Result { + let raw = input.trim(); + if raw.is_empty() { + anyhow::bail!("empty path"); + } + let candidate = Path::new(raw); + // An absolute path is only acceptable if it is already inside the root; + // models often echo back a full path we handed them. + let joined = if candidate.is_absolute() { + candidate.to_path_buf() + } else { + root.join(candidate) + }; + + let lexical = lexical_normalize(&joined); + if !lexical.starts_with(root) { + anyhow::bail!("path escapes the planning scope root: {}", input); + } + + // Canonicalize what exists so symlinks cannot tunnel out. Missing files + // fall back to the (already-checked) lexical form so the caller reports + // "not found" rather than a confusing resolution error. + match lexical.canonicalize() { + Ok(real) => { + if !real.starts_with(root) { + anyhow::bail!("path resolves outside the planning scope root: {}", input); + } + Ok(real) + } + Err(_) => Ok(lexical), + } +} + +/// Canonical form of a scope root, resolved once when a thread is opened. +pub fn canonical_root(root: &Path) -> anyhow::Result { + root.canonicalize() + .map_err(|e| anyhow::anyhow!("planning root {}: {}", root.display(), e)) +} + +/// Display form of a path relative to the root, for thread records and model +/// output. Falls back to the full path if it is somehow not under the root. +pub fn rel_to_root(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.to_string_lossy().to_string()) +} + +/// Outcome of a tool call: text for the model, plus any read to record. +pub struct ToolOutcome { + pub text: String, + pub read: Option, +} + +impl ToolOutcome { + fn plain(text: impl Into) -> ToolOutcome { + ToolOutcome { + text: text.into(), + read: None, + } + } +} + +/// Execute one read-only tool call. Unknown names return an error string +/// rather than failing the turn — the model recovers better from "no such +/// tool" than from a dropped conversation. +pub fn exec(root: &Path, name: &str, args: &serde_json::Value) -> ToolOutcome { + let result = match name { + "read_file" => read_file(root, args), + "list_dir" => list_dir(root, args), + "grep" => grep(root, args), + other => Err(anyhow::anyhow!( + "no such tool: {} (planning threads are read-only; available: \ + read_file, list_dir, grep)", + other + )), + }; + match result { + Ok(outcome) => outcome, + Err(e) => ToolOutcome::plain(format!("[error] {}", e)), + } +} + +fn arg_str(args: &serde_json::Value, key: &str) -> anyhow::Result { + args.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow::anyhow!("missing required argument: {}", key)) +} + +fn read_file(root: &Path, args: &serde_json::Value) -> anyhow::Result { + let input = arg_str(args, "path")?; + let path = resolve_in_root(root, &input)?; + let meta = fs::metadata(&path).map_err(|e| anyhow::anyhow!("cannot read {}: {}", input, e))?; + if meta.is_dir() { + anyhow::bail!("{} is a directory (use list_dir)", input); + } + + let mut bytes = Vec::new(); + fs::File::open(&path)? + .take((MAX_READ_BYTES * 4) as u64) + .read_to_end(&mut bytes)?; + let hash = content_hash(&bytes); + + if bytes.contains(&0) { + return Ok(ToolOutcome { + text: format!("[{} is binary, {} bytes — not shown]", input, meta.len()), + read: Some(ReadRecord { + rel: rel_to_root(root, &path), + hash, + mtime: mtime_secs(&path), + }), + }); + } + + let full = String::from_utf8_lossy(&bytes); + let lines: Vec<&str> = full.lines().collect(); + let offset = args + .get("offset") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + .min(lines.len() as u64) as usize; + let limit = args + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(u64::MAX); + + let mut out = String::new(); + let mut shown = 0usize; + let mut truncated = false; + for (i, line) in lines.iter().enumerate().skip(offset) { + if shown as u64 >= limit { + truncated = true; + break; + } + if out.len() + line.len() + 12 > MAX_READ_BYTES { + truncated = true; + break; + } + out.push_str(&format!("{:>6}\t{}\n", i + 1, line)); + shown += 1; + } + if truncated { + out.push_str(&format!( + "[truncated at line {} of {} — call read_file again with offset={}]\n", + offset + shown, + lines.len(), + offset + shown + )); + } + + Ok(ToolOutcome { + text: out, + read: Some(ReadRecord { + rel: rel_to_root(root, &path), + hash, + mtime: mtime_secs(&path), + }), + }) +} + +fn list_dir(root: &Path, args: &serde_json::Value) -> anyhow::Result { + let input = args + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or(".") + .to_string(); + let path = resolve_in_root(root, &input)?; + let entries = + fs::read_dir(&path).map_err(|e| anyhow::anyhow!("cannot list {}: {}", input, e))?; + + let mut names: Vec = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir && SKIP_DIRS.contains(&name.as_str()) { + continue; + } + names.push(if is_dir { format!("{}/", name) } else { name }); + } + names.sort(); + let total = names.len(); + names.truncate(MAX_LIST_ENTRIES); + let mut text = names.join("\n"); + if total > MAX_LIST_ENTRIES { + text.push_str(&format!( + "\n[{} more entries not shown]", + total - MAX_LIST_ENTRIES + )); + } + if text.is_empty() { + text = "[empty directory]".to_string(); + } + Ok(ToolOutcome::plain(text)) +} + +fn grep(root: &Path, args: &serde_json::Value) -> anyhow::Result { + let pattern = arg_str(args, "pattern")?; + let re = regex::Regex::new(&pattern) + .map_err(|e| anyhow::anyhow!("invalid regex {:?}: {}", pattern, e))?; + let start_input = args + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or(".") + .to_string(); + let start = resolve_in_root(root, &start_input)?; + // Simple extension filter; a full glob engine is more than this needs. + let ext_filter: Option = args + .get("ext") + .and_then(|v| v.as_str()) + .map(|s| s.trim_start_matches('.').to_string()); + + let mut hits: Vec = Vec::new(); + let mut stack = vec![start]; + while let Some(dir) = stack.pop() { + if hits.len() >= MAX_GREP_HITS { + break; + } + let meta = match fs::metadata(&dir) { + Ok(m) => m, + Err(_) => continue, + }; + if meta.is_file() { + scan_file(root, &dir, &re, ext_filter.as_deref(), &mut hits); + continue; + } + let entries = match fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let p = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir { + if SKIP_DIRS.contains(&name.as_str()) || name.starts_with('.') { + continue; + } + stack.push(p); + } else { + scan_file(root, &p, &re, ext_filter.as_deref(), &mut hits); + if hits.len() >= MAX_GREP_HITS { + break; + } + } + } + } + + let text = if hits.is_empty() { + format!("[no matches for {:?}]", pattern) + } else { + let capped = hits.len() >= MAX_GREP_HITS; + let mut t = hits.join("\n"); + if capped { + t.push_str("\n[hit limit reached — narrow the pattern or path]"); + } + t + }; + Ok(ToolOutcome::plain(text)) +} + +fn scan_file( + root: &Path, + path: &Path, + re: ®ex::Regex, + ext_filter: Option<&str>, + hits: &mut Vec, +) { + if let Some(want) = ext_filter { + let ok = path + .extension() + .map(|e| e.to_string_lossy() == want) + .unwrap_or(false); + if !ok { + return; + } + } + let meta = match fs::metadata(path) { + Ok(m) => m, + Err(_) => return, + }; + if meta.len() > MAX_GREP_FILE_BYTES { + return; + } + let bytes = match fs::read(path) { + Ok(b) => b, + Err(_) => return, + }; + if bytes.contains(&0) { + return; + } + let text = String::from_utf8_lossy(&bytes); + let rel = rel_to_root(root, path); + for (i, line) in text.lines().enumerate() { + if hits.len() >= MAX_GREP_HITS { + return; + } + if re.is_match(line) { + let shown: String = line.chars().take(240).collect(); + hits.push(format!("{}:{}:{}", rel, i + 1, shown.trim_end())); + } + } +} + +// ── Tool schemas ────────────────────────────────────────────────────────── + +/// Anthropic Messages API tool definitions. +pub fn anthropic_tools() -> serde_json::Value { + serde_json::json!([ + { + "name": "read_file", + "description": "Read a text file inside the planning scope root. \ + Returns numbered lines. Long files are truncated; \ + call again with a larger offset for more.", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path relative to the scope root."}, + "offset": {"type": "integer", "description": "0-based first line to return."}, + "limit": {"type": "integer", "description": "Maximum lines to return."} + }, + "required": ["path"] + } + }, + { + "name": "list_dir", + "description": "List the entries of a directory inside the scope root. \ + Directory names end with '/'. Build and VCS directories are skipped.", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Directory relative to the scope root. Defaults to the root."} + } + } + }, + { + "name": "grep", + "description": "Search file contents by regular expression, recursively. \ + Returns path:line:text. Use this to locate code before reading it.", + "input_schema": { + "type": "object", + "properties": { + "pattern": {"type": "string", "description": "Rust regex syntax."}, + "path": {"type": "string", "description": "Directory or file to search. Defaults to the root."}, + "ext": {"type": "string", "description": "Only search files with this extension, e.g. 'rs'."} + }, + "required": ["pattern"] + } + } + ]) +} + +/// OpenAI-compatible tool definitions, derived from the Anthropic schemas so +/// the two providers cannot drift apart. +pub fn openai_tools() -> serde_json::Value { + let converted: Vec = anthropic_tools() + .as_array() + .map(|tools| { + tools + .iter() + .map(|t| { + serde_json::json!({ + "type": "function", + "function": { + "name": t["name"], + "description": t["description"], + "parameters": t["input_schema"], + } + }) + }) + .collect() + }) + .unwrap_or_default(); + serde_json::Value::Array(converted) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_root() -> PathBuf { + let base = std::env::temp_dir().join(format!( + "linkshell-planning-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(base.join("src")).unwrap(); + fs::write(base.join("src/lib.rs"), "fn alpha() {}\nfn beta() {}\n").unwrap(); + fs::write(base.join("README.md"), "# hello\n").unwrap(); + base.canonicalize().unwrap() + } + + #[test] + fn resolves_paths_inside_the_root() { + let root = tmp_root(); + let p = resolve_in_root(&root, "src/lib.rs").unwrap(); + assert!(p.starts_with(&root)); + assert_eq!(rel_to_root(&root, &p), "src/lib.rs"); + } + + #[test] + fn rejects_parent_traversal_and_absolute_escapes() { + let root = tmp_root(); + assert!(resolve_in_root(&root, "../etc/passwd").is_err()); + assert!(resolve_in_root(&root, "src/../../etc/passwd").is_err()); + assert!(resolve_in_root(&root, "/etc/passwd").is_err()); + // A missing file inside the root is fine — read_file reports not-found. + assert!(resolve_in_root(&root, "src/nope.rs").is_ok()); + // …but a missing file *outside* it is still rejected. + assert!(resolve_in_root(&root, "../nope.rs").is_err()); + } + + #[test] + fn rejects_symlinks_pointing_outside_the_root() { + let root = tmp_root(); + let link = root.join("escape"); + #[cfg(unix)] + std::os::unix::fs::symlink("/etc", &link).unwrap(); + assert!(resolve_in_root(&root, "escape/passwd").is_err()); + } + + #[test] + fn read_file_returns_numbered_lines_and_records_the_read() { + let root = tmp_root(); + let out = exec( + &root, + "read_file", + &serde_json::json!({"path": "src/lib.rs"}), + ); + assert!(out.text.contains("fn alpha")); + assert!(out.text.contains(" 1\t")); + let rec = out.read.expect("read recorded"); + assert_eq!(rec.rel, "src/lib.rs"); + assert_ne!(rec.hash, 0); + } + + #[test] + fn read_file_offset_and_limit_window_the_output() { + let root = tmp_root(); + let out = exec( + &root, + "read_file", + &serde_json::json!({"path": "src/lib.rs", "offset": 1, "limit": 1}), + ); + assert!(out.text.contains("fn beta")); + assert!(!out.text.contains("fn alpha")); + } + + #[test] + fn grep_finds_matches_with_path_and_line() { + let root = tmp_root(); + let out = exec(&root, "grep", &serde_json::json!({"pattern": "fn beta"})); + assert!(out.text.contains("src/lib.rs:2:"), "got: {}", out.text); + } + + #[test] + fn grep_ext_filter_excludes_other_files() { + let root = tmp_root(); + let out = exec( + &root, + "grep", + &serde_json::json!({"pattern": "hello", "ext": "rs"}), + ); + assert!(out.text.starts_with("[no matches"), "got: {}", out.text); + } + + #[test] + fn list_dir_marks_directories() { + let root = tmp_root(); + let out = exec(&root, "list_dir", &serde_json::json!({})); + assert!(out.text.contains("src/")); + assert!(out.text.contains("README.md")); + } + + #[test] + fn write_tools_do_not_exist() { + let root = tmp_root(); + for name in [ + "write_file", + "edit", + "bash", + "shell", + "apply_patch", + "remember", + ] { + let out = exec( + &root, + name, + &serde_json::json!({"path": "x", "content": "y"}), + ); + assert!( + out.text.contains("no such tool"), + "{} must not be callable, got: {}", + name, + out.text + ); + } + // And the file was not created by any of them. + assert!(!root.join("x").exists()); + } + + #[test] + fn tool_schemas_agree_across_providers() { + let a = anthropic_tools(); + let o = openai_tools(); + let a_names: Vec<&str> = a + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap()) + .collect(); + let o_names: Vec<&str> = o + .as_array() + .unwrap() + .iter() + .map(|t| t["function"]["name"].as_str().unwrap()) + .collect(); + assert_eq!(a_names, o_names); + assert_eq!(a_names, vec!["read_file", "list_dir", "grep"]); + } +} diff --git a/src/ui.rs b/src/ui.rs index 243c383..ff3e867 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; -use crate::app::{App, AppMode, FileBrowserState, NewSessionField, Selection, MENU}; +use crate::app::{App, AppMode, FileBrowserState, MenuAction, NewSessionField, Selection}; use crate::layout::LayoutTree; use crate::session::{SessionKind, SessionState}; use vt100::Screen; @@ -197,11 +197,24 @@ pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { let mut chat_area = Rect::default(); let mut chat_layout = ChatLayout::default(); - let output_areas = split_output_areas(chunks[0], &app.tree); + // Fullscreen planning claims the whole output region, but only that + // region: the session bar and status panel stay, so the sessions you are + // planning against remain visible and their state keeps updating. + let planning_fullscreen = app.planning_fullscreen && app.planning_docked.is_some(); + let output_areas = if planning_fullscreen { + draw_planning_in(f, app, chunks[0], true); + // No pane rects: hit-testing must find nothing where no session was + // drawn, and the PTYs keep the size they last laid out at. + Vec::new() + } else { + split_output_areas(chunks[0], &app.tree) + }; for (pane_idx, area) in output_areas.iter().copied().enumerate() { if app.chat_docked == Some(pane_idx) && output_areas.len() > 1 { chat_layout = draw_chat_in(f, app, area, pane_idx == app.focused_pane); chat_area = chat_layout.area; + } else if app.planning_docked == Some(pane_idx) && output_areas.len() > 1 { + draw_planning_in(f, app, area, pane_idx == app.focused_pane); } else { draw_pane_output(f, app, area, pane_idx, pane_idx == app.focused_pane); } @@ -244,6 +257,9 @@ pub fn draw(f: &mut Frame<'_>, app: &App) -> LayoutInfo { chat_layout = draw_chat(f, app, size); chat_area = chat_layout.area; } + AppMode::OrchestratorModel { selected } => { + help_area = draw_orchestrator_model_picker(f, app, size, *selected); + } AppMode::Menu { .. } => { let menu = draw_menu_bar(f, app, menu_bar_area); menu_item_areas = menu.0; @@ -1171,6 +1187,10 @@ fn draw_help(f: &mut Frame<'_>, area: Rect) -> Rect { ("alt-\\ / alt--", "Split focused pane right / down"), ("alt-w / alt-r / alt-o", "Close / rotate / focus next pane"), ("alt-shift-pgup/pgdn", "Scroll output (any session)"), + ( + "alt-p / alt-shift-p", + "Planning pane: dock / fill output area", + ), ("alt-h", "Show this help"), ("ctrl-space", "Toggle menu bar"), ("ctrl-q", "Quit"), @@ -1755,12 +1775,20 @@ fn draw_menu_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> (Vec, Rect, _ => return (Vec::new(), Rect::default(), Vec::new()), }; + // Rebuilt every frame so labels reflect live state — "Stop" vs "Start", + // the current model, whether a restart is pending. + let sections = app.menu(); + if sections.is_empty() { + return (Vec::new(), Rect::default(), Vec::new()); + } + let selected_top = selected_top.min(sections.len() - 1); + let mut spans = Vec::new(); let mut item_areas = Vec::new(); let mut x = area.x; - for (idx, (label, _)) in MENU.iter().enumerate() { - let text = format!(" {} ", label); - let width = text.len() as u16; + for (idx, section) in sections.iter().enumerate() { + let text = format!(" {} ", section.title); + let width = text.chars().count() as u16; item_areas.push(Rect { x, y: area.y, @@ -1784,8 +1812,16 @@ fn draw_menu_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> (Vec, Rect, return (item_areas, Rect::default(), Vec::new()); }; - let entries = MENU[selected_top].1; - let width = entries.iter().map(|s| s.len()).max().unwrap_or(0) as u16 + 4; + let entries = §ions[selected_top].items; + if entries.is_empty() { + return (item_areas, Rect::default(), Vec::new()); + } + let sub_idx = sub_idx.min(entries.len() - 1); + + // Width fits the widest label+detail pair, then is clamped so a long + // model id cannot push the popup off the right edge of the terminal. + let content_width = entries.iter().map(|e| e.width()).max().unwrap_or(0) as u16; + let width = (content_width + 4).min(area.width.max(8)); let x = item_areas .get(selected_top) .map(|r| r.x) @@ -1805,13 +1841,44 @@ fn draw_menu_bar(f: &mut Frame<'_>, app: &App, area: Rect) -> (Vec, Rect, let rows: Vec = entries .iter() .enumerate() - .map(|(idx, label)| { - let style = if idx == sub_idx { + .map(|(idx, item)| { + if item.action == MenuAction::Separator { + return ListItem::new(Line::from(Span::styled( + "─".repeat(inner.width as usize), + Style::default().fg(Color::DarkGray), + ))); + } + let selected = idx == sub_idx; + let base = if !item.enabled { + Style::default().fg(Color::DarkGray) + } else if selected { Style::default().add_modifier(Modifier::REVERSED) } else { Style::default() }; - ListItem::new(Line::from(Span::styled(format!(" {}", label), style))) + // Pad between label and detail so values right-align into a + // readable column rather than trailing each label. + let label_w = item.label.chars().count(); + let detail_w = item.detail.chars().count(); + let avail = inner.width as usize; + let mut spans = vec![Span::styled(format!(" {}", item.label), base)]; + if detail_w > 0 { + let used = label_w + detail_w + 2; + let pad = avail.saturating_sub(used).max(1); + spans.push(Span::styled(" ".repeat(pad), base)); + let detail_style = if selected || !item.enabled { + base + } else { + base.fg(Color::Cyan) + }; + spans.push(Span::styled(format!("{} ", item.detail), detail_style)); + } else if selected { + // Extend the highlight across the row so selection reads as a + // bar rather than stopping at the end of the text. + let pad = avail.saturating_sub(label_w + 1); + spans.push(Span::styled(" ".repeat(pad), base)); + } + ListItem::new(Line::from(spans)) }) .collect(); f.render_widget(List::new(rows), inner); @@ -2214,10 +2281,805 @@ fn centered_rect(percent_x: u16, height: u16, r: Rect) -> Rect { } } +// ── Planning pane ───────────────────────────────────────────────────────── + +/// Contract `$HOME` to `~` for display. +fn contract_home(path: &std::path::Path) -> String { + let s = path.to_string_lossy().to_string(); + match std::env::var("HOME") { + Ok(home) if !home.is_empty() && s.starts_with(&home) => { + format!("~{}", &s[home.len()..]) + } + _ => s, + } +} + +/// Coarse relative age: the sidebar only needs enough to order things by feel. +fn relative_age(then: u64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let secs = now.saturating_sub(then); + match secs { + 0..=59 => "just now".to_string(), + 60..=3599 => format!("{}m", secs / 60), + 3600..=86399 => format!("{}h", secs / 3600), + 86400..=172_799 => "yesterday".to_string(), + _ => format!("{}d", secs / 86400), + } +} + +/// Truncate to `width` columns, ellipsizing when it doesn't fit. +fn ellipsize(s: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + if s.chars().count() <= width { + return s.to_string(); + } + let keep = width.saturating_sub(1); + let mut out: String = s.chars().take(keep).collect(); + out.push('…'); + out +} + +const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +fn spinner_frame() -> char { + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + SPINNER[(ms / 120) as usize % SPINNER.len()] +} + +/// Render the planning pane into a split leaf. +/// +/// The pane is a document, not a log: a thread list on the left, and on the +/// right a header that says what the thread is grounded in, the transcript, +/// an input that grows with the paragraph you're writing, and a status row. +fn draw_planning_in(f: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { + use crate::app::PlanningFocus; + + f.render_widget(Clear, area); + + let block = Block::default() + .title(" Planning ") + .borders(Borders::ALL) + .border_style(Style::default().fg(if focused { + Color::Cyan + } else { + Color::DarkGray + })); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.width == 0 || inner.height == 0 { + return; + } + + // Sidebar collapses to zero width, giving the transcript the whole pane. + let (sidebar_area, main_area) = if app.planning.sidebar_collapsed { + (Rect { width: 0, ..inner }, inner) + } else { + let pct = app.config.planning.sidebar_width_pct(); + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(pct), Constraint::Min(20)]) + .split(inner); + (cols[0], cols[1]) + }; + + if sidebar_area.width > 2 { + draw_planning_sidebar(f, app, sidebar_area); + } + + let Some(thread) = app.planning.thread.as_ref() else { + let hint = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + " no thread — n to start one", + Style::default().fg(Color::DarkGray), + )), + ]); + f.render_widget(hint, main_area); + // The overlays still have to draw: deleting from the list and + // switching backend are both things you do with no thread open. + draw_planning_overlays(f, app, area); + return; + }; + + // The input grows with its content; the overflow strip and status row take + // fixed rows off the top and bottom of what's left for the transcript. + let input_rows = { + let cols = main_area.width.max(1) as usize; + let chars = app.planning.input.chars().count() + 3; + (chars.div_ceil(cols)).clamp(1, 5) as u16 + }; + let overflow_rows = if app.planning.overflow { 2 } else { 0 }; + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), // header + Constraint::Min(3), // transcript + Constraint::Length(overflow_rows), // overflow prompt + Constraint::Length(input_rows), // input + Constraint::Length(1), // status + ]) + .split(main_area); + + draw_planning_header(f, app, thread, rows[0]); + draw_planning_transcript(f, app, thread, rows[1]); + if overflow_rows > 0 { + draw_planning_overflow(f, app, rows[2]); + } + draw_planning_input( + f, + app, + rows[3], + focused && app.planning.focus == PlanningFocus::Transcript, + ); + draw_planning_status(f, app, thread, rows[4]); + + draw_planning_overlays(f, app, area); +} + +/// Picker and delete confirmation, drawn over the pane in either state. +fn draw_planning_overlays(f: &mut Frame<'_>, app: &App, area: Rect) { + if app.planning.picker.is_some() { + draw_planning_picker(f, app, area); + } + if app.planning.confirm_delete.is_some() { + draw_planning_delete_confirm(f, app, area); + } + if app.planning.handoff.is_some() { + draw_planning_handoff(f, app, area); + } +} + +/// Pick the session a committed plan is handed to as work. +fn draw_planning_handoff(f: &mut Frame<'_>, app: &App, area: Rect) { + let targets = app.planning_handoff_targets(); + let sel = app.planning.handoff.unwrap_or(0); + let height = (targets.len() as u16 + 2).min(area.height).max(3); + let popup = centered_rect(70, height, area); + f.render_widget(Clear, popup); + + let items: Vec = targets + .iter() + .enumerate() + .map(|(i, (id, name))| { + let style = if i == sel { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + ListItem::new(Line::from(vec![ + Span::styled(format!(" {}", name), style), + Span::styled(format!(" #{}", id), Style::default().fg(Color::DarkGray)), + ])) + }) + .collect(); + + f.render_widget( + List::new(items).block( + Block::default() + .title(" Hand plan to ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)), + ), + popup, + ); +} + +/// Thread list: two lines per row so a title has room to breathe. +fn draw_planning_sidebar(f: &mut Frame<'_>, app: &App, area: Rect) { + use crate::app::PlanningFocus; + + let open_id = app.planning.thread.as_ref().map(|t| t.id.as_str()); + let focused_list = app.planning.focus == PlanningFocus::Sidebar; + let width = area.width.saturating_sub(2) as usize; + + let mut lines: Vec = vec![Line::from(Span::styled( + "Threads", + Style::default().add_modifier(Modifier::BOLD), + ))]; + lines.push(Line::from("")); + + // Reserve the bottom rows for pinned decisions and the count footer. + let decisions = app + .planning + .thread + .as_ref() + .map(|t| t.decisions.clone()) + .unwrap_or_default(); + let decisions_rows = if decisions.is_empty() { + 0 + } else { + (decisions.len() + 2).min(area.height as usize / 3) + }; + let list_budget = (area.height as usize) + .saturating_sub(3 + decisions_rows) + .max(2); + + for (i, t) in app.planning.threads.iter().enumerate() { + if lines.len() + 2 > list_budget + 2 { + break; + } + let is_open = open_id == Some(t.id.as_str()); + let is_sel = i == app.planning.list_selected; + let marker = if is_open { "▸ " } else { " " }; + // The open thread stays marked even when focus is elsewhere; the + // selection highlight only means something while the list has focus. + let title_style = if is_open || (is_sel && focused_list) { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + lines.push(Line::from(vec![ + Span::raw(marker), + Span::styled(ellipsize(&t.title, width.saturating_sub(2)), title_style), + ])); + lines.push(Line::from(Span::styled( + format!(" {} · {} msg", relative_age(t.updated), t.messages), + Style::default().fg(Color::DarkGray), + ))); + } + + // Pinned decisions: what you scroll back for is usually "what did we + // decide about X", and a list of those is cheaper to scan than the + // transcript. + if !decisions.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Decisions", + Style::default().add_modifier(Modifier::BOLD), + ))); + for d in decisions.iter().take(decisions_rows.saturating_sub(2)) { + lines.push(Line::from(Span::styled( + format!("· {}", ellipsize(d, width.saturating_sub(2))), + Style::default().fg(Color::Green), + ))); + } + } + + f.render_widget(Paragraph::new(lines), area); + + // Count footer pinned to the last row. + if area.height >= 2 { + let footer = Rect { + x: area.x, + y: area.y + area.height - 1, + width: area.width, + height: 1, + }; + let n = app.planning.threads.len(); + f.render_widget( + Paragraph::new(Span::styled( + format!(" {} thread{}", n, if n == 1 { "" } else { "s" }), + Style::default().fg(Color::DarkGray), + )), + footer, + ); + } +} + +/// Title, scope root, and grounding state. +/// +/// Staleness lives in the persistent chrome rather than a status flash: it is +/// what tells you whether the brief still describes the repo. +fn draw_planning_header( + f: &mut Frame<'_>, + _app: &App, + thread: &crate::planning::store::Thread, + area: Rect, +) { + let stale = thread.stale_reads(); + let reads = thread.reads.len(); + let mut scope: Vec = vec![Span::styled( + format!(" {} · {} files read", contract_home(&thread.root), reads), + Style::default().fg(Color::DarkGray), + )]; + if !stale.is_empty() { + scope.push(Span::styled( + format!(" · {} changed since", stale.len()), + Style::default().fg(Color::Yellow), + )); + } + let lines = vec![ + Line::from(Span::styled( + format!(" {}", thread.title), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(scope), + ]; + f.render_widget(Paragraph::new(lines), area); +} + +/// The transcript, oldest first, with a rule wherever the model changed. +fn draw_planning_transcript( + f: &mut Frame<'_>, + app: &App, + thread: &crate::planning::store::Thread, + area: Rect, +) { + use crate::planning::store::Role; + + let width = area.width.saturating_sub(2) as usize; + let mut lines: Vec = Vec::new(); + let mut prev_model: Option = None; + + for m in &thread.messages { + // Show the model seam: per-message provider/model is recorded so you + // can see where a thread switched from a local model to a frontier + // one, which is how you judge whether earlier turns deserve a re-run. + if m.role == Role::Assistant && !m.model.is_empty() { + let changed = prev_model.as_deref() != Some(m.model.as_str()); + if changed && prev_model.is_some() { + let label = format!(" {} ", m.attribution()); + let rule_w = width.saturating_sub(label.chars().count() + 2); + lines.push(Line::from(Span::styled( + format!("{}{}──", "─".repeat(rule_w), label), + Style::default().fg(Color::DarkGray), + ))); + } + prev_model = Some(m.model.clone()); + } + + let attribution = m.attribution(); + // Hand-edited messages lose their metadata by design — markdown wins + // on text and metadata degrades rather than lying — so an empty + // attribution renders as a generic label, not a blank gutter. + let (label, style) = match m.role { + Role::User => ("you".to_string(), Style::default().fg(Color::DarkGray)), + Role::Assistant if attribution.trim().is_empty() => ( + "assistant".to_string(), + Style::default().fg(Color::DarkGray), + ), + Role::Assistant => (attribution, Style::default().fg(Color::Cyan)), + }; + lines.push(Line::from(Span::styled(format!(" {}", label), style))); + for l in wrap_text(&m.text, width.saturating_sub(2).max(8)) { + lines.push(Line::from(format!(" {}", l))); + } + lines.push(Line::from("")); + } + + if app.planning.busy && !app.planning.status.is_empty() { + lines.push(Line::from(Span::styled( + format!(" {} {}", spinner_frame(), app.planning.status), + Style::default().fg(Color::Yellow), + ))); + } + + // Scroll counts lines up from the tail; 0 pins to the bottom. + let h = area.height as usize; + let total = lines.len(); + let scroll_max = total.saturating_sub(h); + let end = total.saturating_sub(app.planning.scroll.min(scroll_max)); + let start = end.saturating_sub(h); + let window: Vec = lines[start..end].to_vec(); + f.render_widget(Paragraph::new(window), area); +} + +/// Multi-line input. Enter sends; Alt+Enter inserts a newline, because a +/// planning message is usually a paragraph. +fn draw_planning_input(f: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { + let dim = app.planning.busy; + let mut pos = app.planning.cursor.min(app.planning.input.len()); + while pos > 0 && !app.planning.input.is_char_boundary(pos) { + pos -= 1; + } + let (before, after) = app.planning.input.split_at(pos); + let (cursor_ch, rest) = match after.chars().next() { + Some(ch) => (&after[..ch.len_utf8()], &after[ch.len_utf8()..]), + None => (" ", ""), + }; + let base = if dim { + Style::default().fg(Color::DarkGray) + } else { + Style::default() + }; + let mut spans = vec![Span::styled(" > ", Style::default().fg(Color::Cyan))]; + spans.push(Span::styled(before.replace('\n', "⏎"), base)); + if focused && !dim { + spans.push(Span::styled( + cursor_ch.replace('\n', "⏎"), + Style::default().fg(Color::Black).bg(Color::White), + )); + } else { + spans.push(Span::styled(cursor_ch.replace('\n', "⏎"), base)); + } + spans.push(Span::styled(rest.replace('\n', "⏎"), base)); + f.render_widget( + Paragraph::new(Line::from(spans)).wrap(Wrap { trim: false }), + area, + ); +} + +/// Backend, context usage, and whichever of error/status/last-plan matters. +/// Token counts for the context meter. Sub-1k values keep their digits: the +/// old `used / 1000` rendered every short thread as "0k", which reads as a +/// meter that is not measuring anything rather than one reporting a small +/// number. +fn fmt_tokens(n: usize) -> String { + if n < 1000 { + n.to_string() + } else if n < 10_000 { + format!("{:.1}k", n as f64 / 1000.0) + } else { + format!("{}k", n / 1000) + } +} + +fn draw_planning_status( + f: &mut Frame<'_>, + app: &App, + thread: &crate::planning::store::Thread, + area: Rect, +) { + let mut spans: Vec = Vec::new(); + + let label = app.planning.backend_label(); + let backend_style = if app.planning.backend.is_none() { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::DarkGray) + }; + spans.push(Span::styled(format!(" {}", label), backend_style)); + + // Context usage earns permanent space: it is the number whose meaning + // changes when you switch models. + if let Some(b) = app.planning.backend.as_ref() { + if b.max_context_tokens > 0 { + let used = crate::planning::estimate_tokens(&thread.messages, &app.planning.input); + // Two different numbers, and conflating them is what made this + // meter look broken: `used` is what the *next* turn starts from + // (the transcript, which never carries tool traffic), while the + // peak is what the last turn actually sent — file contents and + // all. A turn that reads a codebase moves only the second one. + let peak = app.planning.last_peak_tokens; + let worst = used.max(peak); + let pct = worst * 100 / b.max_context_tokens.max(1); + let style = if pct >= 90 { + Style::default().fg(Color::Red) + } else if pct >= 75 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + spans.push(Span::styled( + format!( + " ~{}/{}", + fmt_tokens(used), + fmt_tokens(b.max_context_tokens) + ), + style, + )); + if peak > used { + spans.push(Span::styled(format!(" (peak {})", fmt_tokens(peak)), style)); + } + } + } + + if !app.planning.error.is_empty() { + spans.push(Span::styled( + format!(" {}", app.planning.error), + Style::default().fg(Color::Red), + )); + } else if app.planning.busy { + spans.push(Span::styled( + format!(" {} {}", spinner_frame(), app.planning.status), + Style::default().fg(Color::Yellow), + )); + } else if !app.planning.status.is_empty() { + // Commit and handoff both land here: they finish by clearing `busy` + // and leaving a confirmation behind, which would otherwise never be + // shown because the spinner branch above is the only other reader. + spans.push(Span::styled( + format!(" {}", app.planning.status), + Style::default().fg(Color::Green), + )); + } else if let Some(p) = crate::planning::store::latest_plan(&thread.id) { + spans.push(Span::styled( + format!(" {}", contract_home(&p)), + Style::default().fg(Color::DarkGray), + )); + } + + f.render_widget(Paragraph::new(Line::from(spans)), area); +} + +/// The one place the design refuses to be silent. Automatic compaction would +/// eat the early turns, and in a planning thread those are usually the design +/// premises everything downstream rests on. +fn draw_planning_overflow(f: &mut Frame<'_>, app: &App, area: Rect) { + let label = app.planning.backend_label(); + let limit = app + .planning + .backend + .as_ref() + .map(|b| b.max_context_tokens) + .unwrap_or(0); + let used = app + .planning + .thread + .as_ref() + .map(|t| crate::planning::estimate_tokens(&t.messages, &app.planning.input)) + .unwrap_or(0); + let lines = vec![ + Line::from(Span::styled( + format!( + " thread is ~{}k tokens, over {}'s {}k limit", + used / 1000, + label, + limit / 1000 + ), + Style::default().fg(Color::Yellow), + )), + Line::from(Span::styled( + " [c] compact [b] switch backend [Esc] dismiss", + Style::default().fg(Color::DarkGray), + )), + ]; + f.render_widget(Paragraph::new(lines), area); +} + +/// Backend picker, reusing the new-session kind-selector idiom rather than +/// inventing a second dropdown. +/// The orchestrator's model list, as reported by its endpoint. +/// +/// A full overlay rather than a menu row: a local server serves dozens of +/// models, and stepping through those one Enter at a time is not choosing. +fn draw_orchestrator_model_picker(f: &mut Frame<'_>, app: &App, area: Rect, sel: usize) -> Rect { + let models = &app.orchestrator_models; + let height = (models.len() as u16 + 2) + .min(area.height.saturating_sub(4)) + .max(3); + let popup = centered_rect(60, height, area); + f.render_widget(Clear, popup); + + let block = Block::default() + .title(" Orchestrator model · ↵ select · r reprobe · esc ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + if models.is_empty() { + f.render_widget( + Paragraph::new(Span::styled( + " endpoint reported no models", + Style::default().fg(Color::Red), + )) + .block(block), + popup, + ); + return popup; + } + + // Keep the highlighted row on screen when the server serves more models + // than the popup has rows. + let rows = popup.height.saturating_sub(2) as usize; + let first = sel.saturating_sub(rows.saturating_sub(1)); + let current = &app.config.orchestrator.model; + let items: Vec = models + .iter() + .enumerate() + .skip(first) + .take(rows) + .map(|(i, m)| { + let style = if i == sel { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let marker = if m == current { "●" } else { " " }; + ListItem::new(Line::from(vec![Span::styled( + format!(" {} {}", marker, m), + style, + )])) + }) + .collect(); + + f.render_widget(List::new(items).block(block), popup); + popup +} + +fn draw_planning_picker(f: &mut Frame<'_>, app: &App, area: Rect) { + let names = app.config.planning.backend_names(); + let sel = app.planning.picker.unwrap_or(0); + if let Some(msel) = app.planning.picker_model { + draw_planning_model_picker( + f, + app, + area, + names.get(sel).cloned().unwrap_or_default(), + msel, + ); + return; + } + let height = (names.len() as u16 + 2).min(area.height).max(3); + let popup = centered_rect(70, height, area); + f.render_widget(Clear, popup); + + if names.is_empty() { + f.render_widget( + Paragraph::new(Span::styled( + " no backends — configure [agents.*] or [planning.backends.*]", + Style::default().fg(Color::Red), + )) + .block( + Block::default() + .title(" Backend ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ), + popup, + ); + return; + } + + let items: Vec = names + .iter() + .enumerate() + .map(|(i, n)| { + let b = app.config.planning.backend(n); + let detail = b + .as_ref() + .map(|b| { + // Three states worth telling apart: probed with a list, + // probed and told nothing, and never asked. + let probe = match app.planning.model_cache.get(n) { + Some(m) if !m.is_empty() => format!(" · {} models ›", m.len()), + Some(_) => " · endpoint unreachable".to_string(), + None if b.is_probeable() => " · probing…".to_string(), + None => String::new(), + }; + format!( + "{} · {} · {}k{}", + b.provider, + if b.model.is_empty() { "—" } else { &b.model }, + b.max_context_tokens / 1000, + probe + ) + }) + .unwrap_or_default(); + let style = if i == sel { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + ListItem::new(Line::from(vec![ + Span::styled(format!(" {:<12}", n), style), + Span::styled( + format!(" {}", detail), + Style::default().fg(Color::DarkGray), + ), + ])) + }) + .collect(); + + f.render_widget( + List::new(items).block( + Block::default() + .title(" Backend · ↵/→ models · r reprobe ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ), + popup, + ); +} + +/// The models a backend's endpoint says it is serving right now. Second level +/// of the picker: an endpoint is not a choice of model, and on a local server +/// the loaded set changes without the config knowing. +fn draw_planning_model_picker( + f: &mut Frame<'_>, + app: &App, + area: Rect, + backend: String, + sel: usize, +) { + let models = app + .planning + .model_cache + .get(&backend) + .cloned() + .unwrap_or_default(); + let current = app.config.planning.backend(&backend).map(|b| b.model); + + // Keep the highlighted row on screen for a server serving more models + // than the popup has rows. + let height = (models.len() as u16 + 2).min(area.height).max(3); + let popup = centered_rect(70, height, area); + let rows = popup.height.saturating_sub(2) as usize; + let first = sel.saturating_sub(rows.saturating_sub(1)); + f.render_widget(Clear, popup); + + let items: Vec = models + .iter() + .enumerate() + .skip(first) + .take(rows) + .map(|(i, m)| { + let style = if i == sel { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let marker = if current.as_deref() == Some(m.as_str()) { + "●" + } else { + " " + }; + ListItem::new(Line::from(vec![Span::styled( + format!(" {} {}", marker, m), + style, + )])) + }) + .collect(); + + f.render_widget( + List::new(items).block( + Block::default() + .title(format!(" Model · {} · ↵ select · ← back ", backend)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ), + popup, + ); +} + +fn draw_planning_delete_confirm(f: &mut Frame<'_>, app: &App, area: Rect) { + let target = app.planning.confirm_delete.as_ref().and_then(|id| { + app.planning + .threads + .iter() + .find(|t| &t.id == id) + .map(|t| (t.title.clone(), t.root.clone())) + }); + let (title, root) = target.unwrap_or_default(); + let popup = centered_rect(60, 5, area); + f.render_widget(Clear, popup); + let lines = vec![ + Line::from(Span::raw(format!(" delete \"{}\"?", ellipsize(&title, 40)))), + // Threads are global, so two repos can hold same-named threads. The + // root is what tells them apart before an irreversible delete. + Line::from(Span::styled( + format!(" {}", ellipsize(&contract_home(&root), 50)), + Style::default().fg(Color::DarkGray), + )), + Line::from(Span::styled( + " [y] delete [n/Esc] cancel", + Style::default().fg(Color::DarkGray), + )), + ]; + f.render_widget( + Paragraph::new(lines).block( + Block::default() + .title(" Confirm ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)), + ), + popup, + ); +} + #[cfg(test)] mod tests { use super::*; + /// `used / 1000` rendered every thread under 1000 tokens as "0k", which + /// reads as a meter that is not measuring anything. + #[test] + fn the_context_meter_keeps_digits_below_a_thousand_tokens() { + assert_eq!(fmt_tokens(0), "0"); + assert_eq!(fmt_tokens(420), "420"); + assert_eq!(fmt_tokens(1_900), "1.9k"); + assert_eq!(fmt_tokens(47_000), "47k"); + assert_eq!(fmt_tokens(131_072), "131k"); + } + #[test] fn palette_popup_never_claims_rows_it_does_not_have() { // Normal case: one row per match, bar keeps its own row. @@ -2388,6 +3250,53 @@ mod tests { assert_eq!(wrap_text("one\ntwo", 10), vec!["one", "two"]); } + #[test] + fn ellipsize_only_truncates_when_it_has_to() { + assert_eq!(ellipsize("short", 10), "short"); + assert_eq!(ellipsize("exactfit", 8), "exactfit"); + // The ellipsis is part of the budget: the result is `width` columns. + assert_eq!(ellipsize("truncate me", 5), "trun…"); + assert_eq!(ellipsize("anything", 0), ""); + } + + #[test] + fn ellipsize_counts_chars_not_bytes() { + // A multi-byte title must not be cut mid-codepoint, and its width is + // measured in columns the terminal draws, not bytes. + assert_eq!(ellipsize("héllo wörld", 20), "héllo wörld"); + assert_eq!(ellipsize("héllo wörld", 5), "héll…"); + assert_eq!(ellipsize("héllo wörld", 5).chars().count(), 5); + } + + #[test] + fn relative_age_buckets_by_magnitude() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + assert_eq!(relative_age(now), "just now"); + assert_eq!(relative_age(now - 120), "2m"); + assert_eq!(relative_age(now - 7200), "2h"); + assert_eq!(relative_age(now - 90_000), "yesterday"); + assert_eq!(relative_age(now - 3 * 86400), "3d"); + // A timestamp from the future must not underflow into a huge age. + assert_eq!(relative_age(now + 500), "just now"); + } + + #[test] + fn contract_home_only_rewrites_the_home_prefix() { + let home = std::env::var("HOME").unwrap_or_default(); + if home.is_empty() { + return; + } + let inside = std::path::PathBuf::from(&home).join("src/linkshell"); + assert_eq!(contract_home(&inside), "~/src/linkshell"); + assert_eq!( + contract_home(std::path::Path::new("/etc/hosts")), + "/etc/hosts" + ); + } + #[test] fn split_layout_returns_two_non_overlapping_output_areas() { let area = Rect::new(3, 4, 101, 20);