From 495fdecfe4abb2c6c0b594686ff3a2216a5001b9 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 29 Jul 2026 13:31:49 +0800 Subject: [PATCH 1/7] docs: add UI/host decoupling plan Full plan for splitting the UI from the backend behind one serializable protocol pipeline (in-app only; remote tcode-to-tcode is future work the pipeline enables, sketched as design guardrails). Co-Authored-By: Claude Fable 5 --- docs/ui-host-decoupling.md | 236 +++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 docs/ui-host-decoupling.md diff --git a/docs/ui-host-decoupling.md b/docs/ui-host-decoupling.md new file mode 100644 index 000000000..bf1a89664 --- /dev/null +++ b/docs/ui-host-decoupling.md @@ -0,0 +1,236 @@ +# tcode UI/host decoupling plan + +Goal of **this effort**: split the UI from the backend behind one serializable +protocol pipeline *inside the desktop app*. Every interaction between views and +backend state crosses a single typed, serde-serializable contract; the desktop +app becomes "client #1 of its own backend". + +Why: the motivating future is remote coding — a tcode on a laptop connecting to +the tcode running on the home Mac, with everything actually happening on the +Mac. That future is **explicitly out of scope here** (no listener, no pairing, +no network transport in this effort); it is what the pipeline enables. §7 +sketches it only so the pipeline is not designed into a corner. + +Status: plan only. Nothing here is implemented yet. Coupling inventory that +grounds this plan was swept 2026-07-29 (all of `crates/ui`, `crates/runtime`). + +## 1. Where we are + +The layering below `runtime` is already clean and mostly serializable: + +- `agent` speaks ACP to providers with a `SessionCommand` → `AgentEvent` + command/event pair; `AgentEvent` is serde. +- Session history is **event-sourced**: per-session `AgentEvent` JSONL, + `Timeline` is a pure fold. Parked-session re-adoption already replays JSONL. + This is the cornerstone: the remote pipeline streams the same events and the + client folds them locally. +- `core` is pure serde data; `services` is fs/git/settings/store. + +The debt is concentrated at the top: + +- `runtime/src/app.rs` (~13k lines) holds `AppState`, a gpui `Entity` mixing + backend authority (stores, provider processes, MCP registries, event pumps) + with window state (`route`, `palette_open`, `sidebar_collapsed`, right-panel + tabs, `debug_*` seeds). +- Production UI reaches **166 distinct `AppState` methods** through + `Entity::read/update` (~550 call sites in 18 files): 95 mutating + commands, 4 backend I/O queries, 67 pure selectors. It also reads 22 pub + fields directly, including the whole folded `active.timeline` as shared + memory, and in a few places *writes* backend fields directly (composer clears + terminal contexts via `active.as_mut()`). +- `runtime` cannot compile headless: `Context` appears in 147 + production signatures, plus `EventEmitter`, `Task`, `BackgroundExecutor`, + public `gpui::Rgba`, and one clipboard write (`copy_plan`). +- Side channels bypass `AppState` entirely: UI holds the live `term::Terminal` + (shared grid + raw PTY byte input); `PreviewPanel` owns the WebViews and + answers preview-MCP broker requests; composer assembles the final provider + prompt (slash commands, terminal selections, review comments) and + base64-encodes attachments on the UI path; settings page performs + computer-use TCC prompts directly. +- `RuntimeEvent` and several boundary DTOs are not serde yet. + +## 2. Target architecture + +``` +┌────────────── client (any machine) ──────────────┐ +│ gpui views · client store (replicated state) │ +│ local emulation: alacritty grid, syntax, diff │ +│ native: WebView, clipboard, drag-drop, dialogs │ +└───────────────┬──────────────────────────────────┘ + │ tcode-protocol: bidirectional JSON-RPC / NDJSON + │ transport now: in-process duplex · future: network (§8) +┌───────────────┴───────────── host ───────────────┐ +│ tcode-host: sessions, providers (ACP), JSONL │ +│ store, settings, git, PTYs, attachments, │ +│ preview/orchestrate/computer-use MCP servers │ +└──────────────────────────────────────────────────┘ +``` + +New crates: + +- **`tcode-protocol`** — the single contract: request/response/event types, + snapshot DTOs, seq numbering, version + capability negotiation. Depends only + on `core`/`agent` types (all serde). No gpui anywhere. +- **`tcode-host`** (evolves from `runtime`) — gpui-free. `AppState` becomes a + plain struct owned by one async task (the "host loop") on smol: consumes a + command channel, emits an event channel, does blocking work on a thread pool. + Multi-client aware from day one. +- **client store** (in `ui` or a new `tcode-client`) — replicated projections + fed by protocol events; gpui views read only this store plus purely local + window state. The 67 pure selectors move into `core`/`protocol` so both sides + share them; no RPC for formatting. + +The host runs **in-process with the desktop app**; in this effort the only +transport is an in-process duplex channel carrying the serialized protocol. +That single seam is the whole point: once the app's own UI drives its backend +exclusively through it, attaching a second (later: remote) client is a +transport problem, not an architecture problem. + +## 3. Protocol design + +Style: bidirectional JSON-RPC 2.0 over newline-delimited JSON — the same idiom +as ACP, which tcode already speaks downward to providers. Symmetry matters: +client→host for commands/queries/subscriptions, host→client for events and +reverse requests (preview automation). One serialized code path everywhere, +including local in-process transport (traffic is UI-scale; honesty beats the +micro-optimization of passing structs). + +Three planes: + +1. **Commands** (the ~95 mutations, named and versioned): `send_turn`, `steer`, + `interrupt`, `respond_approval`, `run_git_action`, `create_project`, + `update_settings`, terminal lifecycle, ACP marketplace ops, … Fire-and-ack + with a request id; results that today arrive as toasts become + request-correlated completions *plus* broadcast state deltas. +2. **Queries** (few, async): the 4 I/O queries (`list_active_workspace`, + `scan_external_history`, `generate_commit_message`, secret presence) plus + the file-shaped access the UI does ad hoc today, made explicit: file bytes + fetch (diff sides, attachment/image bytes), git diff load, fs listing. These + go through the pipeline even in-process so the seam stays honest. +3. **Subscriptions** (host→client push, per domain, snapshot + ordered deltas + with seq numbers): + - `session//events`: snapshot = JSONL replay, delta = live + `AgentEvent`s. The client folds `Timeline` itself — identical to today's + re-adoption path. + - `index`: `SessionMeta`/`Project` upserts (sidebar). + - `settings`: whole-document replace (small). + - `runtime-events`: errors/notices/toasts, made serde; command-triggered + toasts target the issuing client, state changes broadcast. + - `terminal/`: raw output bytes + exit/title events (see §4). + +Two design rules paid for now, cashed in later: every subscription carries seq +numbers and can rebuild from snapshot (in-process this is just the resubscribe +path; remotely it becomes lossless reconnect), and protocol enums are +forward-tolerant (`#[serde(other)]` / value passthrough) so version skew +between two tcodes degrades instead of failing. Neither costs meaningful +complexity today; both are nearly impossible to retrofit. + +## 4. The hard parts, decided + +- **Terminal.** PTY stays host-side; the *emulation* moves client-side. Split + `term` into pty ownership (host) and alacritty grid (client). The wire + carries what SSH carries: output bytes down, input bytes + resize up. No grid + diffing protocol, no shared memory. +- **Preview WebView.** The WebView is native client UI and stays there. The + preview-MCP broker stays host-side (providers connect to it on the host); + its `BrokerRequest`s — which already cross an async channel with a reply + slot — become reverse RPCs over the pipeline to the client owning the panel. + In-process this is nearly a rename of the existing flow. +- **Attachments & images.** Paths in messages are host paths. Client-side + pastes/drops upload bytes via protocol; host validates/transcodes/stores + (today's composer logic moves host-side) and returns the stored path. + Timeline image rendering fetches bytes by path through the query plane with a + client cache. `save_attachment_to_dir`, `remove_user_file`, `read_file_bytes` + and the rest of `ui_facade` become protocol queries; `ui_facade` is deleted. +- **Prompt assembly & slash commands.** Composer currently builds the final + provider prompt (terminal selections, review comments, `/plan`, `/model`, + `/orchestrate` routing) and mutates backend drafts directly. This is business + logic; it moves into the host command handler (`send_turn` takes the typed + text + attachment ids + flags; the host composes). The client keeps only + trigger-menu UX. +- **Native pickers & paths.** Workspace paths are host paths; the UI treats + them as opaque strings. The native directory picker stays (client and host + share a filesystem in this effort), but its *result* enters the backend only + through a command, and fs listing for `@` completion is a protocol query. +- **Computer use.** Entirely host-side (screen/AX). The TCC prompt/relaunch + flow currently in `settings_page.rs` moves behind commands so the UI only + toggles and observes status. +- **Colors/clipboard leaks.** `gpui::Rgba` in accent APIs becomes a hex string + in protocol/core; `copy_plan`'s host-side clipboard write becomes a + client-side effect event (clipboard is always a client device). +- **Window state.** `route`, `palette_open`, `sidebar_collapsed`, right-panel + tab/expansion, terminal drawer height, `debug_*` seeds leave `AppState` and + live client-side (per-window). Per-conversation UI state that today parks in + `conversation_ui` stays client-side keyed by conversation destination. +- **Multi-client.** The protocol never assumes exclusivity: events broadcast, + commands serialize through the host loop, command-triggered toasts target + the issuing client. In this effort there is exactly one client (the app's + own UI), but the contract is written as if there were N. + +## 5. Migration plan — strangler fig, app always shippable + +Each phase compiles, passes `cargo test --workspace`, and ships behind no flag. +Verification per phase: existing smoke mode (`--smoke`), plus a protocol +loopback test harness added in P1. + +- **P0 — Purify the boundary** (mechanical, delegatable). + Split window state out of `AppState` into a client-side struct. Kill direct + field writes from UI (composer's `active.as_mut()`, one-shot debug consumes) + by adding commands. Move prompt assembly + slash-command routing from + composer into runtime. Make `RuntimeEvent` + boundary DTOs serde; replace + public `Rgba` with hex strings; move `copy_plan` clipboard to a UI effect. + Exit: UI performs no direct field mutation; runtime's public surface is + serde-serializable in principle. + +- **P1 — `tcode-protocol` crate.** + Define Command/Query/Event/Subscription enums covering the inventoried + surface (95 commands, 4 queries, per-domain subscriptions), snapshot DTOs, + seq numbers, hello/version. Move the 67 pure selectors to shared code. + Exit: round-trip serde tests for every type; a loopback harness exists. + +- **P2 — Client store; UI reads only replicas** (the bulk; view-by-view, + delegatable per file). + Introduce the client store fed by host events over an in-process channel + (still unserialized at this step). Migrate the 18 UI files off + `Entity` reads onto the store + protocol commands, one view at a + time (suggested order: sidebar → settings → chat → composer → diff → + terminal drawer → panels). Timeline folding moves client-side. + Exit: `crates/ui` has zero `Entity` references. + +- **P3 — Host off gpui; serialize the pipe.** + Replace `Context`/`Task`/`BackgroundExecutor` with smol + channels; host loop + owns state on its own thread; the in-process transport becomes real NDJSON + JSON-RPC. Split `term` (pty host-side, grid client-side; the boundary + carries output bytes down, input bytes + resize up). Preview broker requests + ride the pipeline as reverse RPCs. + Exit — this is the finish line of the whole effort: `tcode-host` compiles + with no gpui dependency; the desktop app runs fully through the serialized + in-process pipe; `crates/ui` has zero `Entity` references; the + smoke suite passes end-to-end over the pipeline. + +Rough effort: P0 and P1 are days each; P2 is the long tail (weeks, +parallelizable per view once the store lands); P3 is a week of concentrated +runtime surgery. + +## 6. Open questions + +- Diff compute placement: client folds diffs today from full file texts; over a + slow link, host-side hunk computation may be worth a capability flag later. +- i18n stays client-side (events are localization-free already — keep it that + way as a protocol invariant). + +## 7. Future (out of scope here): remote tcode-to-tcode + +Kept only as design guardrails, so the pipeline built above needs no rework: + +- The Mac's running tcode would open its own listener (off by default) and the + traveling laptop's tcode would connect directly — tcode-native pairing + (short-lived code shown on the host, then mutual per-device key pinning, + revocable), TLS on the wire, never an unauthenticated port, no ssh involved. + Reachability (NAT) stays the user's network layer; no tcode relay service. +- Remote-only work at that point: connection UI, fs-browser Add Project, + attachment byte upload, image fetch caching, preview port forwarding (a + multiplexed TCP proxy stream in the same connection), reconnect via the seq + numbers §3 already mandates, version-skew handling via the hello exchange. +- Everything in §§2–5 is deliberately already compatible with N clients and a + serialized wire, so none of it should need revisiting. From 1593eb09defe7d588a40a4dce670eb3f78c4c4cd Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 29 Jul 2026 13:39:27 +0800 Subject: [PATCH 2/7] refactor(runtime): make the UI event boundary serializable Serde on RuntimeEvent and boundary DTOs, accent colors as plain 0xRRGGBB integers instead of gpui::Rgba, and the plan-copy clipboard write moved behind a CopyToClipboard effect handled by the app shell. Co-Authored-By: Claude Fable 5 --- crates/core/src/git.rs | 2 +- crates/runtime/src/app.rs | 15 ++++++++------- crates/runtime/src/event.rs | 21 +++++++++++++-------- crates/runtime/src/ui_facade.rs | 4 ++-- crates/ui/src/composer.rs | 4 ++-- crates/ui/src/provider_card.rs | 2 +- crates/ui/src/provider_model_picker.rs | 4 ++-- crates/ui/src/runtime_event.rs | 3 +++ crates/ui/src/shell.rs | 14 +++++++++----- 9 files changed, 41 insertions(+), 28 deletions(-) diff --git a/crates/core/src/git.rs b/crates/core/src/git.rs index 58d792dc0..f53927e41 100644 --- a/crates/core/src/git.rs +++ b/crates/core/src/git.rs @@ -90,7 +90,7 @@ impl GitStatus { // --------------------------------------------------------------------------- /// An executable git operation behind the quick-action button / dropdown. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum GitAction { /// Open the commit dialog, then `git commit` the selected files. Commit, diff --git a/crates/runtime/src/app.rs b/crates/runtime/src/app.rs index 66e0544ee..2060af1d0 100644 --- a/crates/runtime/src/app.rs +++ b/crates/runtime/src/app.rs @@ -2219,7 +2219,7 @@ impl AppState { /// The provider's accent color (`#rrggbb`), when one is configured. Tints /// the provider glyph in the composer + model picker. - pub fn provider_accent(&self, provider: ProviderKind) -> Option { + pub fn provider_accent(&self, provider: ProviderKind) -> Option { let raw = self.settings.provider(provider).accent_color?; parse_hex_color(&raw) } @@ -2283,7 +2283,7 @@ impl AppState { } /// A profile's accent color, when configured. - pub fn profile_accent(&self, id: &str) -> Option { + pub fn profile_accent(&self, id: &str) -> Option { parse_hex_color(&self.profile_settings(id).accent_color?) } @@ -6311,7 +6311,9 @@ impl AppState { /// Copy plan markdown to the clipboard (the "Copy to clipboard" action). pub fn copy_plan(&mut self, markdown: String, cx: &mut Context) { - cx.write_to_clipboard(gpui::ClipboardItem::new_string(markdown)); + cx.emit(AppEvent::Effect(RuntimeEffect::CopyToClipboard { + text: markdown, + })); } /// Write the plan markdown to `PLAN-.md` in the session cwd, choosing the @@ -8102,14 +8104,13 @@ fn assemble_callback_text( format!("[orchestrate] thread {child_id} (\"{title}\") {state}.{token_segment}\n{body}") } -/// Parse a `#rrggbb` accent color into a gpui color; `None` when malformed. -fn parse_hex_color(raw: &str) -> Option { +/// Parse a `#rrggbb` accent color; `None` when malformed. +fn parse_hex_color(raw: &str) -> Option { let hex = raw.trim().trim_start_matches('#'); if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { return None; } - let value = u32::from_str_radix(hex, 16).ok()?; - Some(gpui::rgb(value)) + u32::from_str_radix(hex, 16).ok() } /// A stable settings key for a user-defined ACP agent, derived from its name. diff --git a/crates/runtime/src/event.rs b/crates/runtime/src/event.rs index 3b46c7b4e..20ff10049 100644 --- a/crates/runtime/src/event.rs +++ b/crates/runtime/src/event.rs @@ -3,7 +3,7 @@ use agent::{ProviderKind, RewindMode}; use tcode_core::git::GitAction; -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum RuntimeEvent { Error(RuntimeError), Notice(RuntimeNotice), @@ -11,16 +11,21 @@ pub enum RuntimeEvent { Effect(RuntimeEffect), } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum RuntimeEffect { /// Apply the persisted language override at the localization-aware UI boundary. - ApplyLocale { language: Option }, + ApplyLocale { + language: Option, + }, + CopyToClipboard { + text: String, + }, } -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct RuntimeOperationId(pub u64); -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct GitActionRequest { pub action: GitAction, pub message: Option, @@ -28,7 +33,7 @@ pub struct GitActionRequest { pub feature_branch: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum RuntimeToast { GitBusy, GitStarted { @@ -65,7 +70,7 @@ pub enum RuntimeToast { }, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum RuntimeError { External(String), PersistSettings { error: String }, @@ -90,7 +95,7 @@ pub enum RuntimeError { ProviderMessage(String), } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum RuntimeNotice { ProviderMessage(String), UpdateAvailable { diff --git a/crates/runtime/src/ui_facade.rs b/crates/runtime/src/ui_facade.rs index 325b05261..4ca4c1d49 100644 --- a/crates/runtime/src/ui_facade.rs +++ b/crates/runtime/src/ui_facade.rs @@ -40,7 +40,7 @@ pub fn is_directory(path: &Path) -> bool { tcode_services::user_files::is_directory(path) } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum ExternalImportUpdate { Progress { done: usize, @@ -53,7 +53,7 @@ pub enum ExternalImportUpdate { }, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct AcpMarketplaceItem { pub id: String, pub name: String, diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index f8e75f014..d948ffba7 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -156,7 +156,7 @@ fn provider_short(provider: ProviderKind) -> &'static str { fn tinted_provider_glyph(provider: ProviderKind, app_state: &AppState) -> Icon { let glyph = provider_glyph(provider); match app_state.provider_accent(provider) { - Some(accent) => glyph.text_color(accent), + Some(accent) => glyph.text_color(rgb(accent)), None => glyph, } } @@ -166,7 +166,7 @@ fn tinted_provider_glyph(provider: ProviderKind, app_state: &AppState) -> Icon { fn tinted_profile_glyph(profile_id: &str, app_state: &AppState) -> Icon { let glyph = provider_glyph(app_state.profile_kind(profile_id)); match app_state.profile_accent(profile_id) { - Some(accent) => glyph.text_color(accent), + Some(accent) => glyph.text_color(rgb(accent)), None => glyph, } } diff --git a/crates/ui/src/provider_card.rs b/crates/ui/src/provider_card.rs index a2db2f60b..a73326727 100644 --- a/crates/ui/src/provider_card.rs +++ b/crates/ui/src/provider_card.rs @@ -117,7 +117,7 @@ impl ProviderCard { let provider_icon = provider_glyph(provider).small(); let provider_icon = match accent { - Some(accent) => provider_icon.text_color(accent), + Some(accent) => provider_icon.text_color(rgb(accent)), None => provider_icon, }; let glyph = div() diff --git a/crates/ui/src/provider_model_picker.rs b/crates/ui/src/provider_model_picker.rs index 4e3da7ac1..3ce5f2ecd 100644 --- a/crates/ui/src/provider_model_picker.rs +++ b/crates/ui/src/provider_model_picker.rs @@ -8,7 +8,7 @@ use gpui::{ App, Context, Entity, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window, div, - prelude::FluentBuilder as _, px, + prelude::FluentBuilder as _, px, rgb, }; use gpui_component::{ ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, @@ -398,7 +398,7 @@ fn tinted_glyph(state: &AppState, provider: ProviderKind, profile_id: Option<&st None => state.provider_accent(provider), }; match accent { - Some(accent) => glyph.text_color(accent), + Some(accent) => glyph.text_color(rgb(accent)), None => glyph, } } diff --git a/crates/ui/src/runtime_event.rs b/crates/ui/src/runtime_event.rs index ad89e38e0..d735fdb75 100644 --- a/crates/ui/src/runtime_event.rs +++ b/crates/ui/src/runtime_event.rs @@ -24,6 +24,9 @@ pub(super) fn apply_runtime_effect(effect: &RuntimeEffect) { RuntimeEffect::ApplyLocale { language } => { crate::settings::apply_locale(language.as_deref()); } + RuntimeEffect::CopyToClipboard { .. } => { + unreachable!("clipboard effects are applied by the app shell") + } } } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 8d084b1be..faaec14f1 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -3,9 +3,9 @@ use std::collections::HashMap; use std::rc::Rc; use gpui::{ - AnyElement, App, AppContext as _, Context, Div, ElementId, Entity, InteractiveElement as _, - IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, - StatefulInteractiveElement as _, Styled as _, Subscription, Window, actions, div, + AnyElement, App, AppContext as _, ClipboardItem, Context, Div, ElementId, Entity, + InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, + Render, StatefulInteractiveElement as _, Styled as _, Subscription, Window, actions, div, prelude::FluentBuilder as _, px, }; use gpui_component::{ @@ -14,7 +14,7 @@ use gpui_component::{ resizable::{ResizableState, h_resizable, resizable_panel}, }; use tcode_runtime::app::{AppEvent, AppState, RightTab, Route}; -use tcode_runtime::event::{RuntimeEvent, RuntimeOperationId}; +use tcode_runtime::event::{RuntimeEffect, RuntimeEvent, RuntimeOperationId}; use crate::chat::ChatView; use crate::diff::DiffPanel; @@ -226,11 +226,15 @@ impl AppShell { fn present_app_event(&mut self, event: &AppEvent, window: &mut Window, cx: &mut Context) { let toast = match event { - RuntimeEvent::Effect(effect) => { + RuntimeEvent::Effect(effect @ RuntimeEffect::ApplyLocale { .. }) => { apply_runtime_effect(effect); cx.notify(); return; } + RuntimeEvent::Effect(RuntimeEffect::CopyToClipboard { text }) => { + cx.write_to_clipboard(ClipboardItem::new_string(text.clone())); + return; + } RuntimeEvent::Error(_) | RuntimeEvent::Notice(_) => { let presented = present_runtime_event(event); let notification = match presented.severity { From c570582b5b7a54aaefa87e627490ec6bfb16aadb Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 29 Jul 2026 13:57:16 +0800 Subject: [PATCH 3/7] refactor(ui): move window-global state out of AppState Route, palette, sidebar collapse, quit-confirm state and every debug/ screenshot seed now live in a UI-owned WindowState entity; the runtime keeps only the persisted sidebar preference behind set_sidebar_collapsed and relaunch continuity returns its Settings section for the UI to apply. Co-Authored-By: Claude Fable 5 --- crates/app/src/main.rs | 62 +++++++++++------- crates/runtime/src/app.rs | 108 ++------------------------------ crates/ui/src/acp_panel.rs | 10 ++- crates/ui/src/chat.rs | 34 +++++++--- crates/ui/src/composer.rs | 12 +++- crates/ui/src/diff/view.rs | 29 ++++++--- crates/ui/src/lib.rs | 2 + crates/ui/src/palette.rs | 21 +++++-- crates/ui/src/preview_panel.rs | 21 +++++-- crates/ui/src/settings_page.rs | 37 +++++++---- crates/ui/src/shell.rs | 42 ++++++++----- crates/ui/src/sidebar.rs | 31 ++++++--- crates/ui/src/window_caption.rs | 8 ++- crates/ui/src/window_state.rs | 97 ++++++++++++++++++++++++++++ 14 files changed, 317 insertions(+), 197 deletions(-) create mode 100644 crates/ui/src/window_state.rs diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 560263c47..ecf9b78d0 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -12,7 +12,7 @@ use gpui::{ }; use tcode_runtime::app::AppState; use tcode_services::{shell_env, store::SessionStore}; -use tcode_ui::{AppShell, Quit, TogglePalette}; +use tcode_ui::{AppShell, Quit, TogglePalette, WindowState}; use tcode_ui::{assets, settings}; use gpui_component::{ @@ -53,8 +53,8 @@ fn flatten_canvas_for_opaque_window(theme_json: &str) -> String { } const QUIT_PROMPT_TIMEOUT: Duration = Duration::from_secs(15); -fn finish_quit_prompt(app_state: &Entity, epoch: u64, cx: &mut App) -> bool { - app_state.update(cx, |state, _| { +fn finish_quit_prompt(window_state: &Entity, epoch: u64, cx: &mut App) -> bool { + window_state.update(cx, |state, _| { if !state.quit_prompt_open || state.quit_prompt_epoch != epoch { return false; } @@ -64,7 +64,12 @@ fn finish_quit_prompt(app_state: &Entity, epoch: u64, cx: &mut App) -> }) } -fn handle_quit(_: &Quit, app_state: &Entity, cx: &mut App) { +fn handle_quit( + _: &Quit, + app_state: &Entity, + window_state: &Entity, + cx: &mut App, +) { let count = app_state.read(cx).working_sessions_count(); if count == 0 { cx.quit(); @@ -79,7 +84,7 @@ fn handle_quit(_: &Quit, app_state: &Entity, cx: &mut App) { return; }; - let epoch = app_state.update(cx, |state, _| { + let epoch = window_state.update(cx, |state, _| { if state.quit_prompt_open { return None; } @@ -91,7 +96,7 @@ fn handle_quit(_: &Quit, app_state: &Entity, cx: &mut App) { return; }; - let prompt_state = app_state.clone(); + let prompt_state = window_state.clone(); if window_handle .update(cx, move |_, window, cx| { let quit_state = prompt_state.clone(); @@ -144,12 +149,12 @@ fn handle_quit(_: &Quit, app_state: &Entity, cx: &mut App) { }) .is_err() { - finish_quit_prompt(app_state, epoch, cx); + finish_quit_prompt(window_state, epoch, cx); cx.quit(); return; } - let timeout_state = app_state.clone(); + let timeout_state = window_state.clone(); cx.spawn(async move |cx| { cx.background_executor().timer(QUIT_PROMPT_TIMEOUT).await; cx.update(|cx| { @@ -328,9 +333,12 @@ fn main() { Theme::global_mut(cx).apply_config(&dark); let app_state = cx.new(|_| AppState::new(store)); + let sidebar_collapsed = app_state.read(cx).settings.sidebar_collapsed; + let window_state = cx.new(|_| WindowState::new(sidebar_collapsed)); cx.on_action::({ let app_state = app_state.clone(); - move |action, cx| handle_quit(action, &app_state, cx) + let window_state = window_state.clone(); + move |action, cx| handle_quit(action, &app_state, &window_state, cx) }); // Bring up the in-process preview MCP server and register it with the // app so every spawned agent session can drive the embedded browser. @@ -378,7 +386,7 @@ fn main() { let dsec = debug_settings_section.clone(); let dacp = debug_acp_search.clone(); let dexp = debug_provider_expanded.clone(); - app_state.update(cx, |state, _| { + window_state.update(cx, |state, _| { state.debug_compose = dc; state.debug_image = di; state.debug_diff_scope = dscope; @@ -396,7 +404,14 @@ fn main() { // relaunch, reopen the recorded session and Settings page. Runs // synchronously before the window (and settings page) is built, so // the page mounts already on the recorded section. No-op otherwise. - app_state.update(cx, |state, cx| state.apply_pending_relaunch(cx)); + if let Some(section) = + app_state.update(cx, |state, cx| state.apply_pending_relaunch(cx)) + { + window_state.update(cx, |state, cx| { + state.debug_settings_section = Some(section); + state.open_settings(cx); + }); + } let debug_seed = debug_compose.is_some() || debug_image.is_some() || debug_cwd.is_some() @@ -479,6 +494,7 @@ fn main() { let window = cx .open_window(window_options, { let app_state = app_state.clone(); + let window_state = window_state.clone(); move |window, cx| { match app_state.read(cx).settings.theme_mode { settings::ThemeMode::Light => { @@ -491,7 +507,8 @@ fn main() { Theme::sync_system_appearance(Some(window), cx) } } - let shell = cx.new(|cx| AppShell::new(app_state, window, cx)); + let shell = + cx.new(|cx| AppShell::new(app_state, window_state, window, cx)); cx.new(|cx| Root::new(shell, window, cx)) } }) @@ -552,12 +569,6 @@ fn main() { if terminal_demo { state.open_terminal_demo(cx); } - if open_settings { - state.open_settings(cx); - } - if open_palette { - state.open_palette(cx); - } if let Some(key) = &open_draft && let Some(project) = state .projects @@ -576,9 +587,6 @@ fn main() { if debug_git_genmsg { state.debug_git_generate_message(cx); } - if debug_git_dialog { - state.debug_open_commit_dialog = true; - } if debug_live { state.debug_start_provider(cx); } @@ -610,6 +618,18 @@ fn main() { } } }); + window_state.update(cx, |state, cx| { + if open_settings { + state.open_settings(cx); + } + if open_palette { + state.open_palette(cx); + } + if debug_git_dialog { + state.debug_open_commit_dialog = true; + cx.notify(); + } + }); } }) .detach(); diff --git a/crates/runtime/src/app.rs b/crates/runtime/src/app.rs index 2060af1d0..068ad8bd7 100644 --- a/crates/runtime/src/app.rs +++ b/crates/runtime/src/app.rs @@ -284,14 +284,6 @@ impl ConversationDestination { } } -/// The top-level window route: the chat workspace or the full-page settings. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Route { - #[default] - Chat, - Settings, -} - /// Which tab the right-side panel shows (it hosts the diff view and the /// plan/task view). Cached per conversation destination, in memory only. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -937,17 +929,6 @@ pub struct AppState { native_rewind_prefills: HashMap, pub settings: Settings, pub smoke: Option, - /// Whether the sidebar is collapsed to an icon strip (ephemeral UI state). - pub sidebar_collapsed: bool, - /// Current window route (chat vs. settings page). - pub route: Route, - /// Whether the command palette (⌘K) overlay is showing. - pub palette_open: bool, - /// Generation of the transient quit confirmation. Timers capture this value - /// so an expired prompt cannot dismiss a newer (or unrelated) dialog. - pub quit_prompt_epoch: u64, - /// Prevents repeated quit signals from stacking confirmation dialogs. - pub quit_prompt_open: bool, /// Per-provider model catalog (from `agent::list_models`): loaded instantly /// from the persisted cache, then refreshed in the background at start and /// whenever a binary path changes. Absent entry = never fetched. @@ -963,29 +944,6 @@ pub struct AppState { /// Kept off in unit tests so dispatching a synthetic turn never launches a /// real provider process. Production titles are generated in the background. ai_title_generation_enabled: bool, - /// Screenshot-only: seed the composer text on first render (drives `@`/`/`/`$` - /// trigger menus headlessly, as `--open-diff` does for the diff panel). - pub debug_compose: Option, - /// Screenshot-only: inject a pending image attachment on first render (paste - /// / drag-drop cannot be driven headlessly). - pub debug_image: Option, - /// Screenshot-only diff state seeds. - pub debug_diff_scope: Option, - pub debug_diff_split: bool, - pub debug_diff_scope_menu: bool, - pub debug_review_comment: bool, - /// Screenshot-only: seed the command palette's query when it opens (so the - /// `>`-actions filter and thread result rows can be captured headlessly). - pub debug_palette: Option, - /// Pending Settings section target, consumed by `SettingsPage`. Used by - /// screenshot capture, relaunch continuity, and in-app section links. - pub debug_settings_section: Option, - /// Screenshot-only: seed the ACP marketplace's search box. - pub debug_acp_search: Option, - /// Screenshot-only: open the ACP Add agent dialog on the Providers page. - pub debug_acp_dialog: bool, - /// Screenshot-only: built-in provider-profile id whose card starts expanded. - pub debug_provider_expanded: Option, /// The ACP agent marketplace: the registry index (from the CDN, cached on /// disk with a one-hour TTL), whether a refresh is in flight, and the last /// failure to show when there is nothing cached to fall back on. @@ -1036,10 +994,6 @@ pub struct AppState { store_append_generation: u64, /// Per-session token used to discard superseded timeline loads. timeline_load_generations: HashMap, - /// Screenshot-only (`--debug-git-dialog`): open the commit dialog once the - /// git status has loaded (clicking the header button cannot be driven - /// headlessly). Consumed by `ChatView` on its next render. - pub debug_open_commit_dialog: bool, /// Composer-draft review notes, keyed by session id (in-memory only). review_comment_drafts: HashMap>, /// Invalidates working-tree/branch previews on panel open and turn finish. @@ -1091,7 +1045,6 @@ impl AppState { computer_use_mcp::config::set(computer_use_config(&settings)); // Consume any restart-continuity marker left by a permission grant. let pending_relaunch = tcode_services::relaunch::take(store.root()); - let settings_collapsed = settings.sidebar_collapsed; let terminal_preferences_path = store.root().join("terminal-ui.json"); let terminal_preferences = std::fs::read(&terminal_preferences_path) .ok() @@ -1131,11 +1084,6 @@ impl AppState { native_rewind_prefills: HashMap::new(), settings, smoke: None, - sidebar_collapsed: settings_collapsed, - route: Route::Chat, - palette_open: false, - quit_prompt_epoch: 0, - quit_prompt_open: false, model_catalogs, models_loading: HashMap::new(), terminal_preferences_path, @@ -1144,12 +1092,6 @@ impl AppState { pending_terminal_spawns: HashMap::new(), next_start_generation: 0, ai_title_generation_enabled: !cfg!(test), - debug_compose: None, - debug_image: None, - debug_diff_scope: None, - debug_diff_split: false, - debug_diff_scope_menu: false, - debug_review_comment: false, acp_registry: None, acp_registry_loading: false, acp_registry_error: None, @@ -1173,15 +1115,9 @@ impl AppState { git_status_generation: 0, store_append_generation: 0, timeline_load_generations: HashMap::new(), - debug_open_commit_dialog: false, review_comment_drafts: HashMap::new(), diff_refresh_generation: 0, pending_diff_focus: None, - debug_palette: None, - debug_settings_section: None, - debug_acp_search: None, - debug_acp_dialog: false, - debug_provider_expanded: None, provider_versions: HashMap::new(), provider_snapshots: HashMap::new(), pending_relaunch, @@ -2784,10 +2720,9 @@ impl AppState { .unwrap_or_default() } - pub fn toggle_sidebar_collapsed(&mut self, cx: &mut Context) { - self.sidebar_collapsed = !self.sidebar_collapsed; + pub fn set_sidebar_collapsed(&mut self, collapsed: bool, cx: &mut Context) { // Persist so the choice survives a restart (save errors are cosmetic). - self.settings.sidebar_collapsed = self.sidebar_collapsed; + self.settings.sidebar_collapsed = collapsed; let settings = self.settings.clone(); self.enqueue_settings(&settings, cx); cx.notify(); @@ -3279,36 +3214,6 @@ impl AppState { self.persist_meta(&meta, cx); } - // -- routing + palette -------------------------------------------------- - - /// Switch to the full-page settings route (closes the palette). - pub fn open_settings(&mut self, cx: &mut Context) { - self.palette_open = false; - self.route = Route::Settings; - cx.notify(); - } - - /// Return from settings to the chat workspace. - pub fn close_settings(&mut self, cx: &mut Context) { - self.route = Route::Chat; - cx.notify(); - } - - pub fn open_palette(&mut self, cx: &mut Context) { - self.palette_open = true; - cx.notify(); - } - - pub fn close_palette(&mut self, cx: &mut Context) { - self.palette_open = false; - cx.notify(); - } - - pub fn toggle_palette(&mut self, cx: &mut Context) { - self.palette_open = !self.palette_open; - cx.notify(); - } - /// Reset user settings to defaults, preserving the sidebar's per-project /// collapsed state and the model favorites (UI state, not page settings). /// The theme is reset too; the caller re-applies it to the window. @@ -4240,18 +4145,15 @@ impl AppState { /// Settings on the recorded page. The page reruns a permission recheck as it /// mounts, so the user immediately sees the post-restart status. No-op when /// there is no marker (the normal launch path). - pub fn apply_pending_relaunch(&mut self, cx: &mut Context) { - let Some(marker) = self.pending_relaunch.take() else { - return; - }; + pub fn apply_pending_relaunch(&mut self, cx: &mut Context) -> Option { + let marker = self.pending_relaunch.take()?; if let Some(id) = marker.active_session.as_deref() && self.sessions.iter().any(|meta| meta.id == id) { self.select_session(id, cx); } - self.debug_settings_section = Some(marker.reopen_settings); - self.route = Route::Settings; cx.notify(); + Some(marker.reopen_settings) } // -- archive / delete / rename / unread (Group A) ----------------------- diff --git a/crates/ui/src/acp_panel.rs b/crates/ui/src/acp_panel.rs index 8cd4775d4..9f02999c4 100644 --- a/crates/ui/src/acp_panel.rs +++ b/crates/ui/src/acp_panel.rs @@ -20,6 +20,7 @@ use tcode_runtime::app::AppState; use tcode_runtime::ui_facade::AcpMarketplaceItem; use crate::material; +use crate::window_state::WindowState; /// One installed ACP agent, rendered with the same anatomy as a native provider card. pub struct AcpAgentCard { @@ -295,12 +296,17 @@ pub struct AcpPanel { } impl AcpPanel { - pub fn new(app_state: Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + app_state: Entity, + window_state: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { let input = |placeholder: &str, window: &mut Window, cx: &mut Context| { cx.new(|cx| InputState::new(window, cx).placeholder(placeholder.to_string())) }; let search = input(&tcode_i18n::tr!("providers.acp.search"), window, cx); - if let Some(seed) = app_state.read(cx).debug_acp_search.clone() { + if let Some(seed) = window_state.read(cx).debug_acp_search.clone() { search.update(cx, |input, cx| input.set_value(seed, window, cx)); } let subscriptions = vec![ diff --git a/crates/ui/src/chat.rs b/crates/ui/src/chat.rs index 5e0ae0a05..0374e4999 100644 --- a/crates/ui/src/chat.rs +++ b/crates/ui/src/chat.rs @@ -40,6 +40,7 @@ use crate::terminal_drawer::TerminalDrawer; use crate::time::now_millis; use crate::window_caption; use crate::window_drag_area; +use crate::window_state::WindowState; /// Content-column max width (T3 centers the timeline at ~760px). Shared with /// the composer, which mirrors this column so the input aligns with the @@ -653,6 +654,7 @@ impl MdState { pub struct ChatView { app_state: Entity, + window_state: Entity, composer: Entity, terminal_drawer: Entity, list_state: ListState, @@ -673,8 +675,14 @@ pub struct ChatView { } impl ChatView { - pub fn new(app_state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let composer = cx.new(|cx| Composer::new(app_state.clone(), window, cx)); + pub fn new( + app_state: Entity, + window_state: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let composer = + cx.new(|cx| Composer::new(app_state.clone(), window_state.clone(), window, cx)); let overdraw = timeline_overdraw(f32::from(window.bounds().size.height)); let list_state = ListState::new(0, ListAlignment::Bottom, px(overdraw)); list_state.set_follow_mode(FollowMode::Tail); @@ -696,6 +704,7 @@ impl ChatView { let mut this = Self { app_state, + window_state, composer, terminal_drawer, list_state, @@ -2507,7 +2516,7 @@ impl ChatView { // the row's leading content (the sidebar toggle) is inset past them — // but only when the platform actually draws them: they are hidden in // fullscreen, and other platforms never had them. - let collapsed = self.app_state.read(cx).sidebar_collapsed; + let collapsed = self.window_state.read(cx).sidebar_collapsed; let clears_traffic_lights = cfg!(target_os = "macos") && collapsed && !window.is_fullscreen(); // Windows: with no right panel open this header is the window's @@ -2515,6 +2524,7 @@ impl ChatView { // right edge, past the header's usual inset. let hosts_caption = window_caption::hosts_caption( window_caption::CaptionSurface::Chat, + self.window_state.read(cx).route, self.app_state.read(cx), ); let base = h_flex() @@ -2547,8 +2557,10 @@ impl ChatView { tcode_i18n::tr!("sidebar.collapse") }) .on_click(cx.listener(|this, _, _, cx| { - this.app_state - .update(cx, |state, cx| state.toggle_sidebar_collapsed(cx)); + let app_state = this.app_state.clone(); + this.window_state.update(cx, |state, cx| { + state.toggle_sidebar_collapsed(&app_state, cx) + }); })); // A draft shows a muted "New thread" label; an open thread its title; @@ -3017,8 +3029,9 @@ impl Render for ChatView { // Screenshot-only: `--debug-git-dialog` opens the commit dialog once the // background git status has landed (a header click is not drivable // headlessly). Consumed once. - let open_commit_dialog = self.app_state.update(cx, |state, _| { - let armed = state.debug_open_commit_dialog && state.git_status.is_some(); + let git_status_loaded = self.app_state.read(cx).git_status.is_some(); + let open_commit_dialog = self.window_state.update(cx, |state, _| { + let armed = state.debug_open_commit_dialog && git_status_loaded; if armed { state.debug_open_commit_dialog = false; } @@ -3740,6 +3753,7 @@ mod tests { work_log_summary, }; use crate::markdown::MarkdownState; + use crate::window_state::WindowState; use agent::{FileChange, FileChangeKind, ItemStatus}; use gpui::{AppContext as _, Entity, TestAppContext}; use std::collections::{HashMap, HashSet}; @@ -3876,9 +3890,11 @@ This begins after the hard break."#; ]; state }); + let window_state = cx.new(|_| WindowState::new(false)); - let (view, cx) = - cx.add_window_view(|window, cx| ChatView::new(app_state.clone(), window, cx)); + let (view, cx) = cx.add_window_view(|window, cx| { + ChatView::new(app_state.clone(), window_state, window, cx) + }); let cx: &mut VisualTestContext = cx; cx.simulate_resize(size(px(1_024.), px(700.))); cx.run_until_parked(); diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index d948ffba7..52d637fb5 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -41,6 +41,7 @@ use crate::context_meter; use crate::palette::fuzzy_score; use crate::provider_card::{CLAUDE_BRAND_COLOR, provider_glyph}; use crate::shortcut::format_secondary_shortcut; +use crate::window_state::WindowState; use crate::workspace_walk::filter_entries; use tcode_core::attachments::validate_attachment; use tcode_core::session::append_review_comments_to_prompt; @@ -474,6 +475,7 @@ fn mime_from_path(path: &std::path::Path) -> String { pub struct Composer { app_state: Entity, + window_state: Entity, input: Entity, /// Dedicated free-form answer field shown inside an agent question card. /// Keeping it separate from the turn composer makes the pending question @@ -540,7 +542,12 @@ pub struct Composer { impl EventEmitter for Composer {} impl Composer { - pub fn new(app_state: Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + app_state: Entity, + window_state: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { let input = cx.new(|cx| { InputState::new(window, cx) .multi_line(true) @@ -612,6 +619,7 @@ impl Composer { Self { app_state, + window_state, input, user_input_custom, text_cache: ComposerTextCache::default(), @@ -690,7 +698,7 @@ impl Composer { return; } let (compose, image) = { - let state = self.app_state.read(cx); + let state = self.window_state.read(cx); (state.debug_compose.clone(), state.debug_image.clone()) }; if compose.is_none() && image.is_none() { diff --git a/crates/ui/src/diff/view.rs b/crates/ui/src/diff/view.rs index 9b6749533..a3f01e136 100644 --- a/crates/ui/src/diff/view.rs +++ b/crates/ui/src/diff/view.rs @@ -31,6 +31,7 @@ use super::model::{ use super::parse::RowKind; use crate::plan_panel::PlanPanel; use crate::window_caption; +use crate::window_state::WindowState; use crate::{highlight, material}; use tcode_core::session::{ReviewComment, ReviewSide}; use tcode_runtime::app::{AppState, RightTab}; @@ -215,6 +216,7 @@ struct CommentSelection { pub struct DiffPanel { app_state: Entity, + window_state: Entity, /// The Plan/Tasks tab content (the other tab in this right panel). plan: Entity, /// Soft-wrap toggle for long code lines (the one real toolbar button). @@ -235,7 +237,11 @@ pub struct DiffPanel { } impl DiffPanel { - pub fn new(app_state: Entity, cx: &mut Context) -> Self { + pub fn new( + app_state: Entity, + window_state: Entity, + cx: &mut Context, + ) -> Self { // Soft-wrap defaults to the user's "Word wrap in diffs" setting. let wrap = app_state.read(cx).settings.word_wrap_diffs; let plan = cx.new(|cx| PlanPanel::new(app_state.clone(), cx)); @@ -249,6 +255,7 @@ impl DiffPanel { })]; Self { app_state, + window_state, plan, wrap, ignore_ws: false, @@ -488,11 +495,12 @@ impl DiffPanel { } let debug = { let state = self.app_state.read(cx); + let window_state = self.window_state.read(cx); state.active.as_ref().map(|active| { ( active.meta.id.clone(), - state.debug_diff_scope.clone(), - state.debug_diff_split, + window_state.debug_diff_scope.clone(), + window_state.debug_diff_split, ) }) }; @@ -610,7 +618,7 @@ impl DiffPanel { return false; } self.apply_pending_file_focus(&session, scope, cx); - let debug_comment = self.app_state.read(cx).debug_review_comment + let debug_comment = self.window_state.read(cx).debug_review_comment && self.app_state.read(cx).review_comments().is_empty(); if debug_comment && let Some((scope, file, row_index, line, side, text)) = @@ -666,8 +674,10 @@ impl DiffPanel { row_index, row_index, ); - self.app_state.update(cx, |state, cx| { + self.window_state.update(cx, |state, _| { state.debug_review_comment = false; + }); + self.app_state.update(cx, |state, cx| { state.add_review_comment(comment, cx); }); } @@ -684,8 +694,11 @@ impl DiffPanel { // strip hosts the caption buttons. It is shorter than the 52px shell // header, so grow it to match — the buttons must reach the window top, // and a taller strip keeps the tabs aligned with the chat header. - let hosts_caption = - window_caption::hosts_caption(window_caption::CaptionSurface::RightPanel, state); + let hosts_caption = window_caption::hosts_caption( + window_caption::CaptionSurface::RightPanel, + self.window_state.read(cx).route, + state, + ); // The second tab is "Plan" when a plan exists or the session is in Plan // mode, else "Tasks" (S1 §6). let plan_label = if state.plan_tab_active_label() { @@ -852,7 +865,7 @@ impl DiffPanel { ); let selector = Popover::new("diff-turn-popover") - .default_open(state.debug_diff_scope_menu) + .default_open(self.window_state.read(cx).debug_diff_scope_menu) .trigger(trigger) .content(move |_, _, cx| { let panel_for = panel.clone(); diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 92809d0e4..c0f79b2f6 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -32,7 +32,9 @@ mod terminal_drawer; pub mod time; pub(crate) mod toast; mod window_caption; +mod window_state; mod workspace_walk; pub(crate) use shell::window_drag_area; pub use shell::{AppShell, Quit, TogglePalette}; +pub use window_state::{Route, WindowState}; diff --git a/crates/ui/src/palette.rs b/crates/ui/src/palette.rs index d9a288ba2..7b4f6245b 100644 --- a/crates/ui/src/palette.rs +++ b/crates/ui/src/palette.rs @@ -3,7 +3,7 @@ //! shot 27-cmdk.png. //! //! Rendered by [`crate::AppShell`] as a full-window overlay only while -//! [`tcode_runtime::app::AppState::palette_open`] is set. Sources: +//! [`crate::WindowState::palette_open`] is set. Sources: //! - Threads: fuzzy match over session titles (enter opens the thread). //! - Actions: "New thread…" per project, "Open settings", "Toggle theme", //! "Toggle diff panel". @@ -28,6 +28,7 @@ use crate::provider_card::provider_glyph; use crate::settings::ThemeMode; use crate::settings_page::apply_theme; use crate::time::now_secs; +use crate::window_state::WindowState; /// Score `text` against a fuzzy `query` (case-insensitive subsequence match). /// Returns `None` when `query` is not a subsequence of `text`; a higher score @@ -107,6 +108,7 @@ struct Group { pub struct CommandPalette { app_state: Entity, + window_state: Entity, query: Entity, focus_handle: FocusHandle, selected: usize, @@ -114,7 +116,12 @@ pub struct CommandPalette { } impl CommandPalette { - pub fn new(app_state: Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + app_state: Entity, + window_state: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { let query = cx.new(|cx| { InputState::new(window, cx).placeholder(tcode_i18n::tr!("palette.placeholder")) }); @@ -139,6 +146,7 @@ impl CommandPalette { Self { app_state, + window_state, query, focus_handle: cx.focus_handle(), selected: 0, @@ -150,7 +158,7 @@ impl CommandPalette { /// seeds the query so palette states can be screenshotted headlessly. pub fn focus(&mut self, window: &mut Window, cx: &mut Context) { let seed = self - .app_state + .window_state .read(cx) .debug_palette .clone() @@ -163,7 +171,7 @@ impl CommandPalette { } fn close(&self, cx: &mut Context) { - self.app_state + self.window_state .update(cx, |state, cx| state.close_palette(cx)); } @@ -305,7 +313,7 @@ impl CommandPalette { } Action::OpenSettings => { // open_settings also clears palette_open. - self.app_state + self.window_state .update(cx, |state, cx| state.open_settings(cx)); } Action::ToggleTheme => { @@ -569,8 +577,9 @@ mod tests { impl PaletteHarness { fn new(app_state: Entity, window: &mut Window, cx: &mut Context) -> Self { + let window_state = cx.new(|_| WindowState::new(false)); Self { - palette: cx.new(|cx| CommandPalette::new(app_state, window, cx)), + palette: cx.new(|cx| CommandPalette::new(app_state, window_state, window, cx)), } } } diff --git a/crates/ui/src/preview_panel.rs b/crates/ui/src/preview_panel.rs index b3d1fc949..60089c24f 100644 --- a/crates/ui/src/preview_panel.rs +++ b/crates/ui/src/preview_panel.rs @@ -30,9 +30,9 @@ //! state we don't currently track, so overlapping in-webview popovers are a //! known limitation (documented, not fixed). -use preview_mcp::PreviewReply; #[cfg(any(not(target_os = "linux"), test))] -use tcode_runtime::app::Route; +use crate::window_state::Route; +use preview_mcp::PreviewReply; #[cfg(any(not(target_os = "linux"), test))] fn visible_preview_key( @@ -95,6 +95,7 @@ mod native { ReplyTx, normalize_url, preview_key_for_session, unavailable_message, visible_preview_key, }; use crate::window_caption; + use crate::window_state::WindowState; use tcode_runtime::app::AppState; fn wait_timeout_message(pending: &[String]) -> String { @@ -106,6 +107,7 @@ mod native { pub struct PreviewPanel { app_state: Entity, + window_state: Entity, /// One native WebView per session id, created on first use. webviews: HashMap>, /// Sessions whose WebView has begun a navigation. lb-wry queues (and drops @@ -140,6 +142,7 @@ mod native { impl PreviewPanel { pub fn new( app_state: Entity, + window_state: Entity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -158,6 +161,7 @@ mod native { ]; Self { app_state, + window_state, webviews: HashMap::new(), warm: HashSet::new(), urls: HashMap::new(), @@ -251,10 +255,11 @@ mod native { let active = self.active_key(cx); let visible = { let state = self.app_state.read(cx); + let window_state = self.window_state.read(cx); visible_preview_key( active.as_deref(), - state.route, - state.palette_open, + window_state.route, + window_state.palette_open, state.preview_panel_showing(), ) .map(str::to_string) @@ -833,6 +838,7 @@ mod native { let visible = { let state = self.app_state.read(cx); + let window_state = self.window_state.read(cx); if state.active_session_id() != Some(session_id) { let _ = reply.try_send(Err( "preview is not visible; the user is viewing another conversation".into(), @@ -841,8 +847,8 @@ mod native { } visible_preview_key( Some(key), - state.route, - state.palette_open, + window_state.route, + window_state.palette_open, state.preview_panel_showing(), ) == Some(key) }; @@ -1009,6 +1015,7 @@ mod native { // buttons reach the window's true top-right corner. let hosts_caption = window_caption::hosts_caption( window_caption::CaptionSurface::Preview, + self.window_state.read(cx).route, self.app_state.read(cx), ); h_flex() @@ -1124,6 +1131,7 @@ mod placeholder { use preview_mcp::PreviewOp; use super::ReplyTx; + use crate::window_state::WindowState; use tcode_runtime::app::AppState; pub struct PreviewPanel; @@ -1131,6 +1139,7 @@ mod placeholder { impl PreviewPanel { pub fn new( _app_state: Entity, + _window_state: Entity, _window: &mut Window, _cx: &mut Context, ) -> Self { diff --git a/crates/ui/src/settings_page.rs b/crates/ui/src/settings_page.rs index 0f6b26f15..c5660b149 100644 --- a/crates/ui/src/settings_page.rs +++ b/crates/ui/src/settings_page.rs @@ -1,6 +1,6 @@ //! Full-page settings route (V2-M6). Replaces the old settings dialog. //! -//! When [`tcode_runtime::app::Route::Settings`] is active, the whole window shows this +//! When [`crate::Route::Settings`] is active, the whole window shows this //! page: a left nav (same width as the sidebar) listing sections + a pinned //! "← Back", and a content column of setting rows (bold title + muted //! description on the left, a control on the right), matching reference shots @@ -38,6 +38,7 @@ use crate::shell::Quit; use crate::time::now_secs; use crate::window_caption; use crate::window_drag_area; +use crate::window_state::WindowState; /// Left inset so branding clears the native macOS 26 traffic lights near x=72. #[cfg(target_os = "macos")] @@ -77,6 +78,7 @@ fn apply_toggle_value(settings: &mut Settings, checked: bool, mutate: fn(&mut Se pub struct SettingsPage { app_state: Entity, + window_state: Entity, /// One card per native profile, keyed by profile id (built-in + user). provider_cards: Vec<(String, Entity)>, /// Long-lived state for the modal ACP marketplace and custom form. @@ -104,10 +106,10 @@ pub struct SettingsPage { impl SettingsPage { fn take_requested_section( - app_state: &Entity, + window_state: &Entity, cx: &mut Context, ) -> Option
{ - app_state + window_state .update(cx, |state, _| state.debug_settings_section.take()) .map(|section| match section.as_str() { "providers" => Section::Providers, @@ -119,7 +121,12 @@ impl SettingsPage { }) } - pub fn new(app_state: Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + app_state: Entity, + window_state: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { let title_generation = app_state.read(cx).settings.title_generation.clone(); let title_model_picker = cx.new(|cx| { ProviderModelPicker::selection( @@ -134,10 +141,6 @@ impl SettingsPage { }); let subscriptions = vec![ cx.observe(&app_state, |this, _, cx| { - let app_state = this.app_state.clone(); - if let Some(section) = Self::take_requested_section(&app_state, cx) { - this.section = section; - } let selection = this.app_state.read(cx).settings.title_generation.clone(); this.title_model_picker.update(cx, |picker, cx| { picker.set_selected( @@ -149,6 +152,13 @@ impl SettingsPage { }); cx.notify(); }), + cx.observe(&window_state, |this, _, cx| { + let window_state = this.window_state.clone(); + if let Some(section) = Self::take_requested_section(&window_state, cx) { + this.section = section; + } + cx.notify(); + }), cx.subscribe(&title_model_picker, |this, _, event, cx| { let selected = event.0.clone(); this.update_settings( @@ -164,11 +174,12 @@ impl SettingsPage { // Consume launch-time screenshot/relaunch requests through the same // channel used by in-app Settings links. - let section = Self::take_requested_section(&app_state, cx).unwrap_or(Section::General); - let acp_panel = cx.new(|cx| AcpPanel::new(app_state.clone(), window, cx)); + let section = Self::take_requested_section(&window_state, cx).unwrap_or(Section::General); + let acp_panel = + cx.new(|cx| AcpPanel::new(app_state.clone(), window_state.clone(), window, cx)); let orchestrate_panel = cx.new(|cx| OrchestrateSettingsPanel::new(app_state.clone(), window, cx)); - let debug_acp_dialog_pending = app_state.read(cx).debug_acp_dialog; + let debug_acp_dialog_pending = window_state.read(cx).debug_acp_dialog; let home_url_value = app_state .read(cx) .settings @@ -207,6 +218,7 @@ impl SettingsPage { let perm_status = permissions::check(); let mut page = Self { app_state, + window_state, provider_cards: Vec::new(), acp_panel, orchestrate_panel, @@ -514,7 +526,7 @@ impl SettingsPage { ) .child(tcode_i18n::tr!("settings.back")) .on_click(cx.listener(|this, _, _, cx| { - this.app_state + this.window_state .update(cx, |state, cx| state.close_settings(cx)); })), ), @@ -533,6 +545,7 @@ impl SettingsPage { // therefore end left of the buttons rather than under them. let hosts_caption = window_caption::hosts_caption( window_caption::CaptionSurface::Settings, + self.window_state.read(cx).route, self.app_state.read(cx), ); // The 52px strip spans the paper full-width (drag area), but its title diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index faaec14f1..70a60076b 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -13,7 +13,7 @@ use gpui_component::{ notification::Notification, resizable::{ResizableState, h_resizable, resizable_panel}, }; -use tcode_runtime::app::{AppEvent, AppState, RightTab, Route}; +use tcode_runtime::app::{AppEvent, AppState, RightTab}; use tcode_runtime::event::{RuntimeEffect, RuntimeEvent, RuntimeOperationId}; use crate::chat::ChatView; @@ -29,6 +29,7 @@ use crate::runtime_event::{ present_runtime_toast, }; use crate::toast::{ToastAction, ToastId, ToastKind, ToastSpec}; +use crate::window_state::{Route, WindowState}; actions!(tcode, [Quit, TogglePalette]); @@ -85,6 +86,7 @@ pub(crate) fn window_drag_area( pub struct AppShell { app_state: Entity, + window_state: Entity, sidebar: Entity, chat: Entity, diff: Entity, @@ -154,7 +156,12 @@ fn next_sidebar_overlay_visibility( } impl AppShell { - pub fn new(app_state: Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + app_state: Entity, + window_state: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { let toasts = cx.new(|_| ToastCenter::new()); let window_title = |state: &AppState| -> String { match state.active.as_ref() { @@ -172,7 +179,9 @@ impl AppShell { cx.subscribe_in(&app_state, window, |this, _, event: &AppEvent, w, cx| { this.present_app_event(event, w, cx); }); - let preview = cx.new(|cx| PreviewPanel::new(app_state.clone(), window, cx)); + let window_subscription = cx.observe(&window_state, |_, _, cx| cx.notify()); + let preview = + cx.new(|cx| PreviewPanel::new(app_state.clone(), window_state.clone(), window, cx)); // Pump preview automation requests from the MCP server into the live // WebView. The receiver is taken once; requests are resolved on the gpui @@ -203,15 +212,18 @@ impl AppShell { app_state.update(cx, |state, cx| state.pump_orchestrate_requests(cx)); Self { - sidebar: cx.new(|cx| SessionsSidebar::new(app_state.clone(), cx)), - chat: cx.new(|cx| ChatView::new(app_state.clone(), window, cx)), - diff: cx.new(|cx| DiffPanel::new(app_state.clone(), cx)), + sidebar: cx.new(|cx| SessionsSidebar::new(app_state.clone(), window_state.clone(), cx)), + chat: cx.new(|cx| ChatView::new(app_state.clone(), window_state.clone(), window, cx)), + diff: cx.new(|cx| DiffPanel::new(app_state.clone(), window_state.clone(), cx)), preview, - settings_page: cx.new(|cx| SettingsPage::new(app_state.clone(), window, cx)), - palette: cx.new(|cx| CommandPalette::new(app_state.clone(), window, cx)), + settings_page: cx + .new(|cx| SettingsPage::new(app_state.clone(), window_state.clone(), window, cx)), + palette: cx + .new(|cx| CommandPalette::new(app_state.clone(), window_state.clone(), window, cx)), toasts, operation_toasts: HashMap::new(), app_state, + window_state, palette_was_open: false, split: cx.new(|_| ResizableState::default()), right_width: Rc::new(Cell::new(px(RIGHT_PANEL_WIDTH))), @@ -220,7 +232,7 @@ impl AppShell { last_viewport_width: None, sidebar_restore_pending: false, sidebar_overlay_visible: false, - _subscriptions: vec![subscription, event_subscription], + _subscriptions: vec![subscription, event_subscription, window_subscription], } } @@ -319,7 +331,7 @@ impl AppShell { _window: &mut Window, cx: &mut Context, ) { - self.app_state + self.window_state .update(cx, |state, cx| state.toggle_palette(cx)); } } @@ -341,15 +353,15 @@ impl Render for AppShell { let sheet_layer = Root::render_sheet_layer(window, cx); let dialog_layer = Root::render_dialog_layer(window, cx); let notification_layer = Root::render_notification_layer(window, cx); - let route = self.app_state.read(cx).route; - let palette_open = self.app_state.read(cx).palette_open; + let route = self.window_state.read(cx).route; + let palette_open = self.window_state.read(cx).palette_open; let fullscreen = window.is_fullscreen(); // Focus the palette's search input on the open transition. if palette_open && !self.palette_was_open { self.palette.update(cx, |p, cx| p.focus(window, cx)); } self.palette_was_open = palette_open; - let collapsed = self.app_state.read(cx).sidebar_collapsed; + let collapsed = self.window_state.read(cx).sidebar_collapsed; // The overlay is workspace-only transient state. Clear it synchronously // on route/expanded transitions rather than waiting for pointer input. if !collapsed || route != Route::Chat { @@ -538,7 +550,7 @@ impl Render for AppShell { .occlude() .on_hover(cx.listener(|this, hovered: &bool, _, cx| { let (collapsed, route) = { - let state = this.app_state.read(cx); + let state = this.window_state.read(cx); (state.sidebar_collapsed, state.route) }; let visible = next_sidebar_overlay_visibility( @@ -574,7 +586,7 @@ impl Render for AppShell { .occlude() .on_hover(cx.listener(|this, hovered: &bool, _, cx| { let (collapsed, route) = { - let state = this.app_state.read(cx); + let state = this.window_state.read(cx); (state.sidebar_collapsed, state.route) }; let visible = next_sidebar_overlay_visibility( diff --git a/crates/ui/src/sidebar.rs b/crates/ui/src/sidebar.rs index 65f5b0edf..b5ba5b058 100644 --- a/crates/ui/src/sidebar.rs +++ b/crates/ui/src/sidebar.rs @@ -24,6 +24,7 @@ use tcode_runtime::app::{AppEvent, AppState, ProjectGroup, RuntimeError}; use crate::shortcut::format_secondary_shortcut; use crate::time::now_secs; use crate::window_drag_area; +use crate::window_state::WindowState; /// Left padding on the sidebar's top row so branding clears the native macOS /// traffic lights (ending near x=72 on macOS 26); a small inset elsewhere. @@ -237,6 +238,7 @@ struct RenameState { pub struct SessionsSidebar { app_state: Entity, + window_state: Entity, /// Project ids whose thread list is expanded past the collapsed limit. expanded_groups: HashSet, /// Parent session ids whose direct child rows are folded away. @@ -253,7 +255,11 @@ pub struct SessionsSidebar { } impl SessionsSidebar { - pub fn new(app_state: Entity, cx: &mut Context) -> Self { + pub fn new( + app_state: Entity, + window_state: Entity, + cx: &mut Context, + ) -> Self { let subscriptions = vec![cx.observe(&app_state, |_, _, cx| cx.notify())]; // Launch sweep: the same auto-archive pass expanding a thread list // runs, applied to every project up front so stale threads are gone @@ -285,6 +291,7 @@ impl SessionsSidebar { }; Self { app_state, + window_state, expanded_groups: HashSet::new(), collapsed_parents, renaming: None, @@ -347,10 +354,10 @@ impl SessionsSidebar { settings.auto_archive_notice_shown = true; state.update_settings(settings, cx); }); - let app_state = self.app_state.clone(); + let window_state = self.window_state.clone(); window.open_alert_dialog(cx, move |alert, _, cx| { let alert = alert.bg(cx.theme().popover); - let app_state = app_state.clone(); + let window_state = window_state.clone(); alert .title(tcode_i18n::tr!("sidebar.auto_archive_dialog.title")) .description(tcode_i18n::tr!( @@ -366,7 +373,7 @@ impl SessionsSidebar { .show_cancel(true), ) .on_ok(move |_, _, cx| { - app_state.update(cx, |state, cx| { + window_state.update(cx, |state, cx| { state.debug_settings_section = Some("archived".into()); state.open_settings(cx); }); @@ -787,7 +794,7 @@ impl SessionsSidebar { .cursor_pointer() .hover(|s| s.bg(cx.theme().sidebar_accent)) .on_click(cx.listener(|this, _, _, cx| { - this.app_state + this.window_state .update(cx, |state, cx| state.open_palette(cx)); })) .child( @@ -1053,7 +1060,7 @@ impl SessionsSidebar { .filter(|(notice_project, _)| notice_project == &project_id) { let label = tcode_i18n::tr!("sidebar.auto_archived", count = *count); - let app_state = self.app_state.clone(); + let window_state = self.window_state.clone(); container = container.child( crate::material::accessible_clickable( div(), @@ -1069,7 +1076,7 @@ impl SessionsSidebar { .cursor_pointer() .hover(|s| s.text_color(cx.theme().sidebar_foreground)) .on_click(move |_, _, cx| { - app_state.update(cx, |state, cx| { + window_state.update(cx, |state, cx| { state.debug_settings_section = Some("archived".into()); state.open_settings(cx); }); @@ -1393,7 +1400,7 @@ impl SessionsSidebar { .cursor_pointer() .hover(|s| s.bg(cx.theme().sidebar_accent)) .on_click(cx.listener(|this, _, _, cx| { - this.app_state + this.window_state .update(cx, |state, cx| state.open_settings(cx)); })) .child( @@ -1639,7 +1646,9 @@ mod tests { state.sessions = vec![meta]; }); - let sidebar = cx.new(|cx| SessionsSidebar::new(app_state.clone(), cx)); + let window_state = cx.new(|_| WindowState::new(false)); + let sidebar = + cx.new(|cx| SessionsSidebar::new(app_state.clone(), window_state.clone(), cx)); let (_, cx) = cx.add_window_view(|_, _| WorkingThreadRowProbe); let cx: &mut VisualTestContext = cx; cx.update(|window, cx| { @@ -1721,7 +1730,9 @@ mod tests { state.sessions = sessions; }); - let sidebar = cx.new(|cx| SessionsSidebar::new(app_state.clone(), cx)); + let window_state = cx.new(|_| WindowState::new(false)); + let sidebar = + cx.new(|cx| SessionsSidebar::new(app_state.clone(), window_state.clone(), cx)); app_state.update(cx, |state, _| { let mut archived: Vec<&str> = state diff --git a/crates/ui/src/window_caption.rs b/crates/ui/src/window_caption.rs index 3432b005f..6ea29c20b 100644 --- a/crates/ui/src/window_caption.rs +++ b/crates/ui/src/window_caption.rs @@ -23,7 +23,9 @@ use gpui::{ Styled as _, Window, WindowControlArea, div, px, }; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _}; -use tcode_runtime::app::{AppState, RightTab, Route}; +use tcode_runtime::app::{AppState, RightTab}; + +use crate::window_state::Route; /// Height of a caption strip. Matches the shell's 52px top rows so the cluster /// sits flush with the window top on every host surface. @@ -71,10 +73,10 @@ fn caption_host( } /// Whether `surface` must render the caption cluster this frame. -pub(crate) fn hosts_caption(surface: CaptionSurface, state: &AppState) -> bool { +pub(crate) fn hosts_caption(surface: CaptionSurface, route: Route, state: &AppState) -> bool { caption_host( CLIENT_DECORATED, - state.route, + route, state.diff_panel_open(), state.right_tab(), ) == Some(surface) diff --git a/crates/ui/src/window_state.rs b/crates/ui/src/window_state.rs new file mode 100644 index 000000000..0132a89fb --- /dev/null +++ b/crates/ui/src/window_state.rs @@ -0,0 +1,97 @@ +use std::path::PathBuf; + +use gpui::{Context, Entity}; +use tcode_runtime::app::AppState; + +/// Window-global UI state owned by the GPUI layer. +pub struct WindowState { + pub route: Route, + pub palette_open: bool, + pub sidebar_collapsed: bool, + pub quit_prompt_epoch: u64, + pub quit_prompt_open: bool, + pub debug_compose: Option, + pub debug_image: Option, + pub debug_diff_scope: Option, + pub debug_diff_split: bool, + pub debug_diff_scope_menu: bool, + pub debug_review_comment: bool, + pub debug_palette: Option, + pub debug_settings_section: Option, + pub debug_acp_search: Option, + pub debug_acp_dialog: bool, + pub debug_provider_expanded: Option, + pub debug_open_commit_dialog: bool, +} + +impl WindowState { + pub fn new(sidebar_collapsed: bool) -> Self { + Self { + route: Route::Chat, + palette_open: false, + sidebar_collapsed, + quit_prompt_epoch: 0, + quit_prompt_open: false, + debug_compose: None, + debug_image: None, + debug_diff_scope: None, + debug_diff_split: false, + debug_diff_scope_menu: false, + debug_review_comment: false, + debug_palette: None, + debug_settings_section: None, + debug_acp_search: None, + debug_acp_dialog: false, + debug_provider_expanded: None, + debug_open_commit_dialog: false, + } + } + + pub fn toggle_sidebar_collapsed( + &mut self, + app_state: &Entity, + cx: &mut Context, + ) { + self.sidebar_collapsed = !self.sidebar_collapsed; + app_state.update(cx, |state, cx| { + state.set_sidebar_collapsed(self.sidebar_collapsed, cx) + }); + cx.notify(); + } + + /// Switch to the full-page settings route (closes the palette). + pub fn open_settings(&mut self, cx: &mut Context) { + self.palette_open = false; + self.route = Route::Settings; + cx.notify(); + } + + /// Return from settings to the chat workspace. + pub fn close_settings(&mut self, cx: &mut Context) { + self.route = Route::Chat; + cx.notify(); + } + + pub fn open_palette(&mut self, cx: &mut Context) { + self.palette_open = true; + cx.notify(); + } + + pub fn close_palette(&mut self, cx: &mut Context) { + self.palette_open = false; + cx.notify(); + } + + pub fn toggle_palette(&mut self, cx: &mut Context) { + self.palette_open = !self.palette_open; + cx.notify(); + } +} + +/// The top-level window route: the chat workspace or the full-page settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Route { + #[default] + Chat, + Settings, +} From 6eb8352354fc0303214436f7662a52c80d33f0ba Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 29 Jul 2026 14:06:30 +0800 Subject: [PATCH 4/7] refactor(runtime): route terminal-context clearing through a command The composer no longer reaches into ActiveSession's terminal workspace; clear_terminal_contexts is the last direct backend-field write from UI. Co-Authored-By: Claude Fable 5 --- crates/runtime/src/app.rs | 7 +++++++ crates/ui/src/composer.rs | 4 +--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/runtime/src/app.rs b/crates/runtime/src/app.rs index 068ad8bd7..b7243ce8e 100644 --- a/crates/runtime/src/app.rs +++ b/crates/runtime/src/app.rs @@ -3892,6 +3892,13 @@ impl AppState { } } + /// Drop the attached terminal contexts once a message consuming them is sent. + pub fn clear_terminal_contexts(&mut self) { + if let Some(active) = self.active.as_mut() { + active.terminal_workspace.contexts.clear(); + } + } + pub fn toggle_diff_expanded(&mut self, cx: &mut Context) { if let Some(active) = self.active.as_mut() { active.diff_expanded = !active.diff_expanded; diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 52d637fb5..3a243d9ab 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -881,9 +881,7 @@ impl Composer { self.image_load_generation = self.image_load_generation.wrapping_add(1); self.pending_image_loads = 0; self.app_state.update(cx, |state, cx| { - if let Some(active) = state.active.as_mut() { - active.terminal_workspace.contexts.clear(); - } + state.clear_terminal_contexts(); state.clear_review_comments(); if relay { state.confirm_relay_and_send(sent_text, attachments, cx) From 0f41c270e793804261f82fc463d63377f55bf826 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 29 Jul 2026 14:22:53 +0800 Subject: [PATCH 5/7] refactor(runtime): assemble the provider prompt at the send boundary The four send commands (send_turn, steer, orchestrate_turn, confirm_relay_and_send) now take the user's typed text plus attachment paths; terminal contexts, review comments, attachment encoding and post-send draft clearing all happen runtime-side. Deferred and queued paths reuse private *_assembled routes so nothing assembles twice. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + crates/runtime/Cargo.toml | 1 + crates/runtime/src/app.rs | 241 ++++++++++++++++++++++++++++++++++++-- crates/ui/src/composer.rs | 135 +++------------------ 4 files changed, 247 insertions(+), 131 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 664ce81e1..d7c5bce1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8622,6 +8622,7 @@ version = "0.1.0" dependencies = [ "agent", "async-channel", + "base64 0.23.0", "computer-use-mcp", "gpui", "log", diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index e3f31e9f5..744c1193a 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] agent = { path = "../agent" } async-channel = "2" +base64 = "0.23" computer-use-mcp = { path = "../computer-use-mcp" } gpui = { git = "https://github.com/zed-industries/zed" } log = "0.4" diff --git a/crates/runtime/src/app.rs b/crates/runtime/src/app.rs index b7243ce8e..8631ba81f 100644 --- a/crates/runtime/src/app.rs +++ b/crates/runtime/src/app.rs @@ -11,6 +11,7 @@ use agent::{ PlanResolution, ProviderCommand, ProviderKind, RewindMode, SessionCommand, SessionOptions, ThreadItem, TurnOptions, TurnStatus, list_models, start_session, }; +use base64::Engine as _; use gpui::{BackgroundExecutor, Context, EventEmitter, Task}; use serde::{Deserialize, Serialize}; @@ -29,7 +30,10 @@ use tcode_core::provider_status::ProviderSnapshot; use tcode_core::relay::{ RelayTranscriptOptions, assemble_relay_prompt, has_meaningful_history, render_relay_transcript, }; -use tcode_core::session::{EntryContent, ReviewComment, Timeline, implement_prompt, plan_title}; +use tcode_core::session::{ + EntryContent, ReviewComment, Timeline, append_review_comments_to_prompt, implement_prompt, + plan_title, +}; use tcode_core::settings::{ ChildApprovalMode, EnvVar, ImageMode, OrchestrateSettings, ProjectSort, ProviderProfile, ProviderSettings, ResolvedProfile, Settings, provider_label, @@ -90,6 +94,67 @@ const NATIVE_PROVIDER_KINDS: [ProviderKind; 4] = [ ProviderKind::OpenCode, ]; +/// Best-effort MIME type from a file extension. +pub fn mime_from_path(path: &Path) -> String { + let ext = path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .unwrap_or_default(); + match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "bmp" => "image/bmp", + "tif" | "tiff" => "image/tiff", + _ => "application/octet-stream", + } + .to_string() +} + +fn normalize_terminal_context_text(text: &str) -> String { + text.replace("\r\n", "\n").trim_matches('\n').to_string() +} + +fn append_terminal_contexts_to_prompt(prompt: &str, contexts: &[TerminalContext]) -> String { + let prompt = prompt.trim(); + let mut lines = Vec::new(); + for context in contexts { + let text = normalize_terminal_context_text(&context.text); + if text.is_empty() || context.terminal_label.trim().is_empty() { + continue; + } + let range = if context.line_start == context.line_end { + format!("line {}", context.line_start) + } else { + format!("lines {}-{}", context.line_start, context.line_end) + }; + if !lines.is_empty() { + lines.push(String::new()); + } + lines.push(format!("- {} {}:", context.terminal_label.trim(), range)); + lines.extend( + text.lines() + .enumerate() + .map(|(index, line)| format!(" {} | {}", context.line_start + index, line)), + ); + } + if lines.is_empty() { + return prompt.to_string(); + } + let block = format!( + "\n{}\n", + lines.join("\n") + ); + if prompt.is_empty() { + block + } else { + format!("{prompt}\n\n{block}") + } +} + #[derive(Debug, Clone, Copy)] enum TimelineLoadTarget { Active { @@ -1316,6 +1381,17 @@ impl AppState { /// Enable orchestration on first use, restart so the MCP registration is /// present, and submit the provider-specific guidance plus the user's text. pub fn orchestrate_turn( + &mut self, + text: String, + attachment_paths: Vec, + cx: &mut Context, + ) { + let (text, attachments) = self.assemble_user_message(text, attachment_paths); + self.orchestrate_turn_assembled(text, attachments, cx); + self.clear_consumed_draft_context(); + } + + fn orchestrate_turn_assembled( &mut self, text: String, attachments: Vec, @@ -1363,7 +1439,7 @@ impl AppState { // `steer` sends ordinarily when idle and injects into a live turn. On // first enable the restart above intentionally makes this an ordinary // queued send for the resumed, MCP-enabled process. - self.steer(text, attachments, cx); + self.steer_assembled(text, attachments, cx); } fn orchestrate_registration_for( @@ -3886,19 +3962,52 @@ impl AppState { } } - pub fn clear_review_comments(&mut self) { + fn clear_review_comments(&mut self) { if let Some(id) = self.active.as_ref().map(|active| active.meta.id.clone()) { self.review_comment_drafts.remove(&id); } } /// Drop the attached terminal contexts once a message consuming them is sent. - pub fn clear_terminal_contexts(&mut self) { + fn clear_terminal_contexts(&mut self) { if let Some(active) = self.active.as_mut() { active.terminal_workspace.contexts.clear(); } } + /// Assemble the provider-bound message at the runtime boundary. Unreadable + /// attachment files are skipped, matching the composer's previous behavior. + fn assemble_user_message( + &self, + text: String, + attachment_paths: Vec, + ) -> (String, Vec) { + let terminal_contexts = self + .active + .as_ref() + .map(|active| active.terminal_workspace.contexts.as_slice()) + .unwrap_or_default(); + let text = append_terminal_contexts_to_prompt(&text, terminal_contexts); + let text = append_review_comments_to_prompt(&text, self.review_comments()); + let attachments = attachment_paths + .into_iter() + .filter_map(|path| { + let bytes = fs::read(&path).ok()?; + Some(Attachment { + media_type: mime_from_path(&path), + data_base64: base64::engine::general_purpose::STANDARD.encode(bytes), + source_path: Some(path.to_string_lossy().into_owned()), + }) + }) + .collect(); + (text, attachments) + } + + fn clear_consumed_draft_context(&mut self) { + self.clear_terminal_contexts(); + self.clear_review_comments(); + } + pub fn toggle_diff_expanded(&mut self, cx: &mut Context) { if let Some(active) = self.active.as_mut() { active.diff_expanded = !active.diff_expanded; @@ -4692,7 +4801,7 @@ impl AppState { active.draft_workspace = WorkspaceMode::LocalCheckout; active.git_branch = git_branch; // Now that the worktree exists, run the deferred send. - state.send_turn(text, attachments, cx); + state.send_turn_assembled(text, attachments, cx); } Err(err) => { active.draft_workspace = WorkspaceMode::LocalCheckout; @@ -5219,6 +5328,17 @@ impl AppState { /// Submit a user turn. Starts the provider lazily if needed. pub fn send_turn( + &mut self, + text: String, + attachment_paths: Vec, + cx: &mut Context, + ) { + let (text, attachments) = self.assemble_user_message(text, attachment_paths); + self.send_turn_assembled(text, attachments, cx); + self.clear_consumed_draft_context(); + } + + fn send_turn_assembled( &mut self, text: String, attachments: Vec, @@ -5336,6 +5456,17 @@ impl AppState { /// The queue carries the provider-only transcript separately, so replay and /// chat rendering never expose the injected preamble as user-authored text. pub fn confirm_relay_and_send( + &mut self, + text: String, + attachment_paths: Vec, + cx: &mut Context, + ) { + let (text, attachments) = self.assemble_user_message(text, attachment_paths); + self.confirm_relay_and_send_assembled(text, attachments, cx); + self.clear_consumed_draft_context(); + } + + fn confirm_relay_and_send_assembled( &mut self, text: String, attachments: Vec, @@ -5345,7 +5476,7 @@ impl AppState { return; }; let Some(pending) = active.pending_relay.take() else { - self.send_turn(text, attachments, cx); + self.send_turn_assembled(text, attachments, cx); return; }; let transcript = render_relay_transcript( @@ -5727,17 +5858,30 @@ impl AppState { /// /// A steered message IS part of the conversation, so it is recorded to the /// session JSONL as a user message (unlike a merely queued one). - pub fn steer(&mut self, text: String, attachments: Vec, cx: &mut Context) { + pub fn steer(&mut self, text: String, attachment_paths: Vec, cx: &mut Context) { + let (text, attachments) = self.assemble_user_message(text, attachment_paths); + self.steer_assembled(text, attachments, cx); + self.clear_consumed_draft_context(); + } + + fn steer_assembled( + &mut self, + text: String, + attachments: Vec, + cx: &mut Context, + ) { let Some(active) = self.active.as_ref() else { return; }; match active.route(true) { // Nothing is running, so there is nothing to steer into: an ordinary // send is exactly the right thing. - SendRouting::Send | SendRouting::Queue => self.send_turn(text, attachments, cx), + SendRouting::Send | SendRouting::Queue => { + self.send_turn_assembled(text, attachments, cx) + } SendRouting::QueueUnsupported => { let agent = active.meta.provider.display_name(); - self.send_turn(text, attachments, cx); + self.send_turn_assembled(text, attachments, cx); self.report_error( RuntimeError::SteerUnsupported { agent: agent.to_string(), @@ -5790,7 +5934,7 @@ impl AppState { // `steer` consumes the session's armed Ultrathink flag, but this // message captured its own at queue time — re-arm so it rides along. active.pending_ultrathink = message.ultrathink; - self.steer(message.text, message.attachments, cx); + self.steer_assembled(message.text, message.attachments, cx); } /// Queue strip: drop a queued message (the row's ✕). It was never recorded, @@ -6104,7 +6248,7 @@ impl AppState { cx, ); self.set_interaction_mode(InteractionMode::Build, cx); - self.send_turn(implement_prompt(&markdown), Vec::new(), cx); + self.send_turn_assembled(implement_prompt(&markdown), Vec::new(), cx); } /// Leave the plan captured in history while removing its actionable @@ -6214,7 +6358,7 @@ impl AppState { _pump: None, }); self.refresh_session_git_branch(session_id, cwd, cx); - self.send_turn(implement_prompt(&markdown), Vec::new(), cx); + self.send_turn_assembled(implement_prompt(&markdown), Vec::new(), cx); cx.notify(); } @@ -8852,6 +8996,79 @@ mod tests { assert!(acp.contains("Generic lead")); } + #[gpui::test] + fn send_turn_assembles_draft_context_and_attachment_paths(cx: &mut gpui::TestAppContext) { + let root = + std::env::temp_dir().join(format!("tcode-send-assembly-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let attachment_path = root.join("sample.png"); + std::fs::write(&attachment_path, [1, 2, 3]).unwrap(); + let store = SessionStore::open_at(root.clone()).unwrap(); + let state = cx.new(|_| AppState::new(store)); + let (commands, receiver) = async_channel::unbounded(); + + state.update(cx, |state, cx| { + let mut active = live_session(ProviderKind::Codex, commands); + active.meta.id = "assembled".into(); + active.terminal_workspace.contexts.push(TerminalContext { + id: 1, + terminal_label: "zsh".into(), + line_start: 12, + line_end: 13, + text: "cargo test\nok".into(), + }); + state.active = Some(active); + state.add_review_comment( + ReviewComment::new( + "src/lib.rs".into(), + 7, + 7, + tcode_core::session::ReviewSide::New, + "Please fix".into(), + "let bad = true;".into(), + "section".into(), + "Changes".into(), + 3, + 4, + ), + cx, + ); + + state.send_turn("Explain this".into(), vec![attachment_path.clone()], cx); + + let SessionCommand::SendTurn { + text, attachments, .. + } = receiver.try_recv().expect("assembled send command") + else { + panic!("expected SendTurn") + }; + assert_eq!( + text, + "Explain this\n\n\n- zsh lines 12-13:\n 12 | cargo test\n 13 | ok\n\n\n\nPlease fix\n```diff\nlet bad = true;\n```\n" + ); + assert_eq!( + attachments, + vec![Attachment { + media_type: "image/png".into(), + data_base64: "AQID".into(), + source_path: Some(attachment_path.to_string_lossy().into_owned()), + }] + ); + assert!( + state + .active + .as_ref() + .unwrap() + .terminal_workspace + .contexts + .is_empty() + ); + assert!(state.review_comments().is_empty()); + }); + + let _ = std::fs::remove_dir_all(root); + } + #[gpui::test] fn orchestrate_turn_records_the_context_split_on_the_user_message( cx: &mut gpui::TestAppContext, diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 3a243d9ab..e402e5a82 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -9,7 +9,7 @@ use std::rc::Rc; use std::path::PathBuf; use agent::{ - ApprovalDecision, ApprovalKind, ApprovalMode, ApprovalOptionKind, ApprovalRequest, Attachment, + ApprovalDecision, ApprovalKind, ApprovalMode, ApprovalOptionKind, ApprovalRequest, FileChangeKind, InteractionMode, ModelSpec, OptionDescriptor, ProviderCommand, ProviderCommandKind, ProviderKind, TokenUsage, UserInputQuestion, }; @@ -44,8 +44,7 @@ use crate::shortcut::format_secondary_shortcut; use crate::window_state::WindowState; use crate::workspace_walk::filter_entries; use tcode_core::attachments::validate_attachment; -use tcode_core::session::append_review_comments_to_prompt; -use tcode_runtime::app::{AppState, TerminalContext, WorkspaceMode}; +use tcode_runtime::app::{AppState, WorkspaceMode, mime_from_path}; use tcode_runtime::ui_facade::PathEntry; /// Blue-500 (normal meter) and red-500 (>90% overloaded), matching T3. @@ -61,50 +60,6 @@ const PICKER_PROVIDER_KINDS: [ProviderKind; 4] = [ ProviderKind::OpenCode, ]; -fn normalize_terminal_context_text(text: &str) -> String { - text.replace("\r\n", "\n").trim_matches('\n').to_string() -} - -pub(crate) fn append_terminal_contexts_to_prompt( - prompt: &str, - contexts: &[TerminalContext], -) -> String { - let prompt = prompt.trim(); - let mut lines = Vec::new(); - for context in contexts { - let text = normalize_terminal_context_text(&context.text); - if text.is_empty() || context.terminal_label.trim().is_empty() { - continue; - } - let range = if context.line_start == context.line_end { - format!("line {}", context.line_start) - } else { - format!("lines {}-{}", context.line_start, context.line_end) - }; - if !lines.is_empty() { - lines.push(String::new()); - } - lines.push(format!("- {} {}:", context.terminal_label.trim(), range)); - lines.extend( - text.lines() - .enumerate() - .map(|(index, line)| format!(" {} | {}", context.line_start + index, line)), - ); - } - if lines.is_empty() { - return prompt.to_string(); - } - let block = format!( - "\n{}\n", - lines.join("\n") - ); - if prompt.is_empty() { - block - } else { - format!("{prompt}\n\n{block}") - } -} - /// T3's circular stop button red-orange. const STOP_TINT: u32 = 0xF4562E; /// Below this measured control-row width the row collapses its context / @@ -453,26 +408,6 @@ fn transcode_image_to_png(bytes: &[u8]) -> image::ImageResult> { Ok(png.into_inner()) } -/// Best-effort MIME type from a file extension (for drag/drop of image files). -fn mime_from_path(path: &std::path::Path) -> String { - let ext = path - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_ascii_lowercase()) - .unwrap_or_default(); - match ext.as_str() { - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "webp" => "image/webp", - "gif" => "image/gif", - "svg" => "image/svg+xml", - "bmp" => "image/bmp", - "tif" | "tiff" => "image/tiff", - _ => "application/octet-stream", - } - .to_string() -} - pub struct Composer { app_state: Entity, window_state: Entity, @@ -768,7 +703,6 @@ impl Composer { .as_ref() .map(|active| active.terminal_workspace.contexts.clone()) .unwrap_or_default(); - let review_comments = self.app_state.read(cx).review_comments().to_vec(); if !self.has_sendable_content(cx) { return; } @@ -802,15 +736,12 @@ impl Composer { } let orchestrate_text = strip_orchestrate_prefix(&text).map(str::to_string); let prompt_text = orchestrate_text.as_deref().unwrap_or(&text); - let text = append_terminal_contexts_to_prompt(prompt_text, &terminal_contexts); - let text = append_review_comments_to_prompt(&text, &review_comments); - // Attachments are persisted on disk (see `add_image_*`); read + - // base64-encode each one so the provider receives real image content - // blocks. An image-only message keeps its empty text here — the runtime - // substitutes T3's synthetic placeholder on the wire only, so the - // transcript bubble renders as just the thumbnails. - let sent_text = text; - let attachments = self.collect_attachments(); + let sent_text = prompt_text.to_string(); + let attachment_paths = self + .pending_images + .iter() + .map(|image| image.path.clone()) + .collect::>(); if let Some((from, to)) = self.app_state.read(cx).relay_confirmation() { let composer = cx.entity(); let input = input.clone(); @@ -819,7 +750,7 @@ impl Composer { let composer = composer.clone(); let input = input.clone(); let sent_text = sent_text.clone(); - let attachments = attachments.clone(); + let attachment_paths = attachment_paths.clone(); alert .title(tcode_i18n::tr!("composer.relay_title")) .description(tcode_i18n::tr!( @@ -838,7 +769,7 @@ impl Composer { composer.finish_submit( &input, sent_text.clone(), - attachments.clone(), + attachment_paths.clone(), false, false, true, @@ -854,7 +785,7 @@ impl Composer { self.finish_submit( input, sent_text, - attachments, + attachment_paths, orchestrate_text.is_some(), steer, false, @@ -868,7 +799,7 @@ impl Composer { &mut self, input: &Entity, sent_text: String, - attachments: Vec, + attachment_paths: Vec, orchestrate: bool, steer: bool, relay: bool, @@ -881,16 +812,14 @@ impl Composer { self.image_load_generation = self.image_load_generation.wrapping_add(1); self.pending_image_loads = 0; self.app_state.update(cx, |state, cx| { - state.clear_terminal_contexts(); - state.clear_review_comments(); if relay { - state.confirm_relay_and_send(sent_text, attachments, cx) + state.confirm_relay_and_send(sent_text, attachment_paths, cx) } else if orchestrate { - state.orchestrate_turn(sent_text, attachments, cx) + state.orchestrate_turn(sent_text, attachment_paths, cx) } else if steer { - state.steer(sent_text, attachments, cx) + state.steer(sent_text, attachment_paths, cx) } else { - state.send_turn(sent_text, attachments, cx) + state.send_turn(sent_text, attachment_paths, cx) } }); cx.emit(ComposerEvent::Submitted); @@ -1308,23 +1237,6 @@ impl Composer { } } - /// Read each pending image off disk and base64-encode it into a wire - /// [`Attachment`]. Unreadable files are skipped (they were validated on add). - fn collect_attachments(&self) -> Vec { - use base64::Engine as _; - self.pending_images - .iter() - .filter_map(|image| { - let bytes = tcode_runtime::ui_facade::read_file_bytes(&image.path).ok()?; - Some(Attachment { - media_type: mime_from_path(&image.path), - data_base64: base64::engine::general_purpose::STANDARD.encode(&bytes), - source_path: Some(image.path.to_string_lossy().into_owned()), - }) - }) - .collect() - } - fn remove_image(&mut self, index: usize, cx: &mut Context) { if index < self.pending_images.len() { let removed = self.pending_images.remove(index); @@ -4857,21 +4769,6 @@ mod tests { ); } - #[test] - fn serializes_terminal_context_like_t3() { - let contexts = vec![TerminalContext { - id: 1, - terminal_label: "zsh".into(), - line_start: 12, - line_end: 13, - text: "cargo test\nok".into(), - }]; - assert_eq!( - append_terminal_contexts_to_prompt("Explain this", &contexts), - "Explain this\n\n\n- zsh lines 12-13:\n 12 | cargo test\n 13 | ok\n" - ); - } - #[test] fn context_label_variants() { let _locale_guard = crate::settings::TestLocaleGuard::acquire(); From 71dd5459543b203e07b7885496bb619422563b1a Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 29 Jul 2026 14:23:19 +0800 Subject: [PATCH 6/7] docs: record P0 completion in the decoupling plan Co-Authored-By: Claude Fable 5 --- docs/ui-host-decoupling.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/ui-host-decoupling.md b/docs/ui-host-decoupling.md index bf1a89664..a8e29f1c3 100644 --- a/docs/ui-host-decoupling.md +++ b/docs/ui-host-decoupling.md @@ -11,7 +11,7 @@ Mac. That future is **explicitly out of scope here** (no listener, no pairing, no network transport in this effort); it is what the pipeline enables. §7 sketches it only so the pipeline is not designed into a corner. -Status: plan only. Nothing here is implemented yet. Coupling inventory that +Status: **P0 implemented** (2026-07-29); P1–P3 planned. Coupling inventory that grounds this plan was swept 2026-07-29 (all of `crates/ui`, `crates/runtime`). ## 1. Where we are @@ -173,14 +173,17 @@ Each phase compiles, passes `cargo test --workspace`, and ships behind no flag. Verification per phase: existing smoke mode (`--smoke`), plus a protocol loopback test harness added in P1. -- **P0 — Purify the boundary** (mechanical, delegatable). - Split window state out of `AppState` into a client-side struct. Kill direct - field writes from UI (composer's `active.as_mut()`, one-shot debug consumes) - by adding commands. Move prompt assembly + slash-command routing from - composer into runtime. Make `RuntimeEvent` + boundary DTOs serde; replace - public `Rgba` with hex strings; move `copy_plan` clipboard to a UI effect. - Exit: UI performs no direct field mutation; runtime's public surface is - serde-serializable in principle. +- **P0 — Purify the boundary** ✅ done. + Window state (route, palette, sidebar collapse, quit confirm, debug seeds) + lives in a UI-owned `WindowState`; the UI performs no direct backend-field + writes; the send commands take typed text + attachment paths and the runtime + assembles terminal contexts, review comments and attachment encoding itself; + `RuntimeEvent` + boundary DTOs are serde; accents are plain `u32` colors; + the plan-copy clipboard write is a UI-handled effect. + Deliberate deviation from the original wording: slash-command *interception* + (`/plan` `/default` `/model`, and `/orchestrate` prefix detection) stays in + the composer — it is input UX in the same class as the trigger menus; the + runtime remains authoritative for everything that reaches a provider. - **P1 — `tcode-protocol` crate.** Define Command/Query/Event/Subscription enums covering the inventoried From 5de5d70f6c34bd69e5f4be9daf9c8b27022a45d1 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 29 Jul 2026 14:43:46 +0800 Subject: [PATCH 7/7] feat(protocol): add the tcode-protocol contract crate (decoupling P1) Command (76 variants) / Query / Event planes with a seq-numbered subscription envelope, NDJSON wire framing, forward-tolerant tagged enums, protocol-side mirrors for not-yet-serde runtime/services DTOs, and round-trip + unknown-variant tests. Purely additive; the two workspace-inventory guard tests register the new crate. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 11 ++ Cargo.toml | 1 + crates/protocol/Cargo.toml | 14 ++ crates/protocol/src/command.rs | 225 +++++++++++++++++++++++++++++++++ crates/protocol/src/event.rs | 224 ++++++++++++++++++++++++++++++++ crates/protocol/src/lib.rs | 46 +++++++ crates/protocol/src/query.rs | 133 +++++++++++++++++++ crates/protocol/src/tests.rs | 197 +++++++++++++++++++++++++++++ crates/protocol/src/wire.rs | 127 +++++++++++++++++++ crates/runtime/src/blocking.rs | 1 + crates/services/src/process.rs | 1 + 11 files changed, 980 insertions(+) create mode 100644 crates/protocol/Cargo.toml create mode 100644 crates/protocol/src/command.rs create mode 100644 crates/protocol/src/event.rs create mode 100644 crates/protocol/src/lib.rs create mode 100644 crates/protocol/src/query.rs create mode 100644 crates/protocol/src/tests.rs create mode 100644 crates/protocol/src/wire.rs diff --git a/Cargo.lock b/Cargo.lock index d7c5bce1a..225fbeee9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8616,6 +8616,17 @@ dependencies = [ "sys-locale", ] +[[package]] +name = "tcode-protocol" +version = "0.1.0" +dependencies = [ + "agent", + "base64 0.23.0", + "serde", + "serde_json", + "tcode-core", +] + [[package]] name = "tcode-runtime" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 017e7eb5f..ea6905bc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/agent", "crates/core", + "crates/protocol", "crates/i18n", "crates/services", "crates/runtime", diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml new file mode 100644 index 000000000..f0d292e27 --- /dev/null +++ b/crates/protocol/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "tcode-protocol" +version = "0.1.0" +edition = "2024" + +[lib] +name = "tcode_protocol" + +[dependencies] +agent = { path = "../agent" } +base64 = "0.23" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tcode-core = { path = "../core" } diff --git a/crates/protocol/src/command.rs b/crates/protocol/src/command.rs new file mode 100644 index 000000000..b8ba3f5c3 --- /dev/null +++ b/crates/protocol/src/command.rs @@ -0,0 +1,225 @@ +use std::path::PathBuf; + +use agent::{ApprovalDecision, ApprovalMode, InteractionMode, ProviderKind, RewindMode}; +use serde::{Deserialize, Serialize}; +use tcode_core::{git::GitAction, settings::Settings}; + +/// A backend mutation requested by a client. +/// +/// Variants correspond to serializable `AppState` mutations used by the UI. +/// UI-only consuming selectors and methods whose signatures contain +/// non-serializable types are intentionally absent. +#[non_exhaustive] +#[allow(clippy::large_enum_variant)] // Wire DTOs preserve direct, typed payload fields. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum Command { + OrchestrateTurn { + text: String, + attachment_paths: Vec, + }, + ReloadProvider { + provider: ProviderKind, + }, + SetProfileSecret { + profile_id: String, + name: String, + value: Option, + }, + CreateThirdPartyProfile { + name: String, + base_url: String, + model: Option, + api_key: String, + }, + DeleteProfile { + profile_id: String, + }, + RefreshProviderStatus, + CheckProviderVersions, + UpdateProvider { + provider: ProviderKind, + }, + SetSidebarCollapsed { + collapsed: bool, + }, + RunGitAction { + action: GitAction, + message: Option, + included: Option>, + feature_branch: Option, + }, + RefreshAcpRegistry, + InstallAcpAgent { + id: String, + }, + RemoveAcpAgent { + id: String, + }, + AddCustomAcpAgent { + name: String, + command: String, + args: Vec, + env: Vec<(String, String)>, + }, + SetActiveAcpAgent { + id: String, + }, + ResetSettings, + ToggleDiffPanel, + OpenDiffForTurn { + turn: usize, + }, + OpenDiffForFile { + turn: usize, + path: String, + }, + DiscardDiffFocus, + SetTerminalHeight { + height: f32, + }, + ToggleTerminalPanel, + CloseTerminalPanel, + RestartTerminal, + NewTerminal, + ActivateTerminal { + terminal_id: u64, + }, + CloseTerminal { + terminal_id: u64, + }, + CaptureTerminalSelection { + terminal_id: u64, + }, + RemoveTerminalContext { + context_id: u64, + }, + CloseDiffPanel, + RemoveReviewComment { + index: usize, + }, + ToggleDiffExpanded, + CycleProjectSort, + CreateProject { + root: PathBuf, + }, + FinishExternalImport { + project_id: String, + }, + ToggleProjectCollapsed { + project_id: String, + }, + UpdateSettings { + settings: Settings, + }, + ArchiveSession { + session_id: String, + }, + UnarchiveSession { + session_id: String, + }, + AutoArchiveSweep { + project_id: String, + }, + RenameSession { + session_id: String, + title: String, + }, + ForkThread { + id: String, + }, + DeleteSession { + session_id: String, + remove_worktree: bool, + }, + DeleteProject { + project_id: String, + }, + MarkSessionUnread { + session_id: String, + }, + StartDraft { + project_id: String, + cwd: PathBuf, + }, + SelectSession { + session_id: String, + }, + SendTurn { + text: String, + attachment_paths: Vec, + }, + ConfirmRelayAndSend { + text: String, + attachment_paths: Vec, + }, + Steer { + text: String, + attachment_paths: Vec, + }, + SteerQueued { + id: u64, + }, + DropQueued { + id: u64, + }, + Interrupt, + RespondApproval { + request_id: String, + decision: ApprovalDecision, + }, + RespondUserInput { + request_id: String, + answers: serde_json::Map, + }, + SetActiveModel { + provider: ProviderKind, + model: Option, + profile_id: Option, + }, + SetActiveOption { + id: String, + value: Option, + }, + SelectUltrathink, + SetInteractionMode { + mode: InteractionMode, + }, + ToggleInteractionMode, + ImplementPlan, + DismissPlan, + ImplementPlanInNewThread { + title: String, + }, + CopyPlan { + markdown: String, + }, + SavePlanToWorkspace { + markdown: String, + }, + DownloadPlan { + markdown: String, + fallback_title: String, + }, + TogglePlanPanel, + TogglePreviewPanel, + ClosePreviewPanel, + OpenPreviewPanel, + OpenPreviewPanelFor { + session_id: String, + }, + LoadBranches, + CheckoutBranch { + branch: String, + }, + SetActiveApprovalMode { + mode: ApprovalMode, + }, + ToggleFavoriteModel { + model: String, + }, + RewindTurn { + turn: usize, + mode: RewindMode, + }, +} diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs new file mode 100644 index 000000000..8cbe56896 --- /dev/null +++ b/crates/protocol/src/event.rs @@ -0,0 +1,224 @@ +use agent::{AgentEvent, ProviderKind, RewindMode}; +use serde::{Deserialize, Serialize}; +use tcode_core::{ + git::GitAction, + project::{Project, SessionMeta}, + settings::Settings, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionEventRecord { + pub ts: Option, + pub event: AgentEvent, +} + +impl PartialEq for SessionEventRecord { + fn eq(&self, other: &Self) -> bool { + serde_json::to_value(self).ok() == serde_json::to_value(other).ok() + } +} + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum Topic { + SessionEvents { session_id: String }, + Index, + Settings, + RuntimeEvents, + Terminal { terminal_id: u64 }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventEnvelope { + pub topic: Topic, + pub seq: u64, + pub event: ServerEvent, +} + +impl PartialEq for EventEnvelope { + fn eq(&self, other: &Self) -> bool { + serde_json::to_value(self).ok() == serde_json::to_value(other).ok() + } +} + +#[non_exhaustive] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum ServerEvent { + SessionEvent(SessionEventRecord), + IndexUpsertSession(SessionMeta), + IndexUpsertProject(Project), + IndexRemoveSession { + session_id: String, + }, + IndexRemoveProject { + project_id: String, + }, + SettingsReplaced(Settings), + Runtime(RuntimeNotification), + TerminalOutput { + #[serde(with = "crate::wire::base64_bytes")] + bytes: Vec, + }, + TerminalExit { + exit_code: Option, + }, + SessionSnapshot(Vec), + IndexSnapshot(IndexSnapshot), + SettingsSnapshot(Settings), + RuntimeSnapshot(RuntimeSnapshot), + TerminalSnapshot(TerminalSnapshot), +} + +impl PartialEq for ServerEvent { + fn eq(&self, other: &Self) -> bool { + serde_json::to_value(self).ok() == serde_json::to_value(other).ok() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndexSnapshot { + pub sessions: Vec, + pub projects: Vec, +} + +impl PartialEq for IndexSnapshot { + fn eq(&self, other: &Self) -> bool { + serde_json::to_value(self).ok() == serde_json::to_value(other).ok() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeSnapshot { + pub notifications: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TerminalSnapshot { + #[serde(with = "crate::wire::base64_bytes")] + pub bytes: Vec, + pub exit_code: Option, +} + +/// Protocol-owned mirror of `tcode_runtime::event::RuntimeEvent`. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum RuntimeNotification { + Error(RuntimeError), + Notice(RuntimeNotice), + Toast(RuntimeToast), + Effect(RuntimeEffect), +} + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum RuntimeEffect { + ApplyLocale { language: Option }, + CopyToClipboard { text: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RuntimeOperationId(pub u64); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GitActionRequest { + pub action: GitAction, + pub message: Option, + pub included: Option>, + pub feature_branch: Option, +} + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum RuntimeToast { + GitBusy, + GitStarted { + operation: RuntimeOperationId, + action: GitAction, + }, + GitSucceeded { + operation: RuntimeOperationId, + action: GitAction, + }, + GitFailed { + operation: RuntimeOperationId, + detail: String, + retry: GitActionRequest, + }, + CommitMessageGenerated { + message: String, + }, + CommitMessageFailed { + detail: String, + }, + AcpInstallStarted { + operation: RuntimeOperationId, + name: String, + }, + AcpInstallSucceeded { + operation: RuntimeOperationId, + name: String, + }, + AcpInstallFailed { + operation: RuntimeOperationId, + name: String, + detail: String, + }, +} + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum RuntimeError { + External(String), + PersistSettings { error: String }, + UpdateUnknown { provider: ProviderKind }, + UpdateFailed { provider: ProviderKind }, + TerminalStart { error: String }, + TerminalRestart { error: String }, + PersistProject { error: String }, + WorktreeRemove { error: String }, + DeleteSession { error: String }, + DeleteProject { error: String }, + NativeRewindBlocked, + PersistEvent { error: String }, + WorktreeAdd { error: String }, + PersistSession { error: String }, + ProcessGone, + SteerUnsupported { agent: String }, + DirtyTree, + ProviderStart { error: String }, + ProviderClosed { reason: Option }, + PersistSessionIndex { error: String }, + ProviderMessage(String), +} + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum RuntimeNotice { + ProviderMessage(String), + UpdateAvailable { + provider: ProviderKind, + version: String, + }, + UpdatingProvider { + provider: ProviderKind, + }, + UpdateDone { + provider: ProviderKind, + }, + NativeRewindCompleted { + mode: RewindMode, + }, + PlanSaved { + file: String, + }, + SwitchedBranch { + branch: String, + }, +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs new file mode 100644 index 000000000..60c696b34 --- /dev/null +++ b/crates/protocol/src/lib.rs @@ -0,0 +1,46 @@ +//! Serializable contract between tcode clients and hosts. +//! +//! Data-carrying enums deliberately use explicit `type`/`content` tagging. +//! Unknown data-carrying variants are decode errors; callers should use the +//! wire helpers, which turn those errors into [`ProtocolError`] values. + +mod command; +mod event; +mod query; +mod wire; + +pub use command::Command; +pub use event::{ + EventEnvelope, GitActionRequest, IndexSnapshot, RuntimeEffect, RuntimeError, RuntimeNotice, + RuntimeNotification, RuntimeOperationId, RuntimeSnapshot, RuntimeToast, ServerEvent, + SessionEventRecord, TerminalSnapshot, Topic, +}; +pub use query::{ + ExternalThread, GitDiffResult, GitDiffScope, GitFileText, PathEntry, Query, QueryResponse, + RecentDir, SourceTool, +}; +pub use wire::{ + ClientMessage, ClientPayload, HostMessage, ProtocolError, ReverseRequest, ReverseResponse, + Subscription, decode_client_line, decode_host_line, encode_line, +}; + +use serde::{Deserialize, Serialize}; + +pub const PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Hello { + pub protocol_version: u32, + pub app_version: String, + pub capabilities: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HelloAck { + pub protocol_version: u32, + pub app_version: String, + pub capabilities: Vec, +} + +#[cfg(test)] +mod tests; diff --git a/crates/protocol/src/query.rs b/crates/protocol/src/query.rs new file mode 100644 index 000000000..e74c4dfe2 --- /dev/null +++ b/crates/protocol/src/query.rs @@ -0,0 +1,133 @@ +use std::path::PathBuf; + +use agent::FileChange; +use serde::{Deserialize, Serialize}; + +/// Read-only or result-bearing host operation. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum Query { + ListActiveWorkspace, + ScanExternalHistory, + GenerateCommitMessage { + included: Option>, + }, + SecretPresence { + profile_id: String, + name: String, + }, + LoadGitDiff { + cwd: PathBuf, + scope: GitDiffScope, + base: Option, + ignore_whitespace: bool, + }, + ReadFileBytes { + path: PathBuf, + }, + RemoveUserFile { + path: PathBuf, + }, + IsDirectory { + path: PathBuf, + }, + RelativizeToWorkspace { + path: String, + cwd: PathBuf, + }, +} + +/// Typed response paired with a [`Query`]. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum QueryResponse { + ActiveWorkspace(Vec), + ExternalHistory(Vec), + CommitMessage(String), + SecretPresence(bool), + GitDiff(GitDiffResult), + FileBytes(#[serde(with = "crate::wire::base64_bytes")] Vec), + UserFileRemoved, + IsDirectory(bool), + RelativePath(String), +} + +/// Protocol mirror of `tcode_services::git::GitDiffScope`. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GitDiffScope { + WorkingTree, + Branch, + #[serde(other)] + Unknown, +} + +/// Protocol mirror of `tcode_services::git::GitFileText`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct GitFileText { + pub old: Option, + pub new: Option, +} + +/// Protocol mirror of `tcode_services::git::GitDiffResult`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct GitDiffResult { + pub changes: Vec, + pub texts: Vec, + pub truncated: bool, + pub error: Option, + pub branches: Vec, + pub default_base: Option, +} + +impl PartialEq for GitDiffResult { + fn eq(&self, other: &Self) -> bool { + serde_json::to_value(self).ok() == serde_json::to_value(other).ok() + } +} + +impl Eq for GitDiffResult {} + +/// Protocol mirror of `tcode_services::workspace::PathEntry`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PathEntry { + pub rel_path: String, + pub basename: String, + pub parent: String, + pub is_dir: bool, +} + +/// Protocol mirror of `tcode_services::import::SourceTool`. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceTool { + ClaudeCode, + ClaudeDesktop, + T3Code, + CodexCli, + CodexDesktop, + #[serde(other)] + Unknown, +} + +/// Protocol mirror of `tcode_services::import::ExternalThread`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalThread { + pub source: SourceTool, + pub file: PathBuf, + pub external_id: String, + pub title_hint: Option, + pub last_active_ms: u64, +} + +/// Protocol mirror of `tcode_services::import::RecentDir`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecentDir { + pub path: PathBuf, + pub last_active_ms: u64, + pub threads: Vec, +} diff --git a/crates/protocol/src/tests.rs b/crates/protocol/src/tests.rs new file mode 100644 index 000000000..950ab2deb --- /dev/null +++ b/crates/protocol/src/tests.rs @@ -0,0 +1,197 @@ +use std::path::PathBuf; + +use agent::{AgentEvent, ProviderKind}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::json; +use tcode_core::{project::Project, settings::Settings}; + +use super::*; + +fn round_trip(value: &T) +where + T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug, +{ + let json = serde_json::to_string(value).unwrap(); + let decoded = serde_json::from_str::(&json).unwrap(); + assert_eq!(&decoded, value); +} + +#[test] +fn round_trips_top_level_wire_types() { + let hello = Hello { + protocol_version: PROTOCOL_VERSION, + app_version: "0.1.0".into(), + capabilities: vec!["terminal".into()], + }; + round_trip(&hello); + round_trip(&HelloAck { + protocol_version: PROTOCOL_VERSION, + app_version: "0.1.0".into(), + capabilities: vec!["terminal".into()], + }); + + let command = Command::SendTurn { + text: "hello".into(), + attachment_paths: vec![PathBuf::from("/tmp/image.png")], + }; + round_trip(&command); + let query = Query::LoadGitDiff { + cwd: PathBuf::from("/tmp/project"), + scope: GitDiffScope::WorkingTree, + base: None, + ignore_whitespace: true, + }; + round_trip(&query); + let response = QueryResponse::FileBytes(vec![0, 1, 2, 254, 255]); + round_trip(&response); + + let subscription = Subscription { + topic: Topic::SessionEvents { + session_id: "session-1".into(), + }, + after_seq: Some(4), + }; + round_trip(&subscription); + + let client = ClientMessage { + id: 7, + payload: ClientPayload::Command(command), + }; + round_trip(&client); + round_trip(&ClientPayload::Subscribe(subscription)); + + let event = EventEnvelope { + topic: Topic::RuntimeEvents, + seq: 9, + event: ServerEvent::Runtime(RuntimeNotification::Notice( + RuntimeNotice::UpdateAvailable { + provider: ProviderKind::Codex, + version: "1.2.3".into(), + }, + )), + }; + round_trip(&event); + round_trip(&HostMessage::Event(event)); + round_trip(&ProtocolError { + code: "not_found".into(), + message: "missing".into(), + }); + round_trip(&ReverseRequest { + method: "preview.click".into(), + params: json!({"selector": "#submit"}), + }); + round_trip(&ReverseResponse { + request_id: 44, + result: Ok(json!({"clicked": true})), + }); +} + +#[test] +fn round_trips_event_and_snapshot_families() { + let stored = SessionEventRecord { + ts: Some(123), + event: AgentEvent::TurnStarted { + turn_id: "turn-1".into(), + }, + }; + round_trip(&stored); + round_trip(&ServerEvent::SessionEvent(stored.clone())); + round_trip(&ServerEvent::SessionSnapshot(vec![stored])); + + let project = Project { + id: "project-1".into(), + name: "Project".into(), + root: PathBuf::from("/tmp/project"), + created_at: 1, + }; + round_trip(&ServerEvent::IndexUpsertProject(project.clone())); + round_trip(&IndexSnapshot { + sessions: Vec::new(), + projects: vec![project], + }); + round_trip(&ServerEvent::SettingsSnapshot(Settings::default())); + round_trip(&RuntimeNotification::Toast(RuntimeToast::GitBusy)); + round_trip(&RuntimeError::ProviderClosed { + reason: Some("done".into()), + }); + round_trip(&GitActionRequest { + action: tcode_core::git::GitAction::Commit, + message: Some("message".into()), + included: Some(vec!["src/lib.rs".into()]), + feature_branch: None, + }); + round_trip(&RuntimeOperationId(17)); + round_trip(&RuntimeSnapshot { + notifications: vec![RuntimeNotification::Effect( + RuntimeEffect::CopyToClipboard { + text: "plan".into(), + }, + )], + }); + round_trip(&ServerEvent::TerminalOutput { + bytes: vec![0, b'\n', 255], + }); + round_trip(&TerminalSnapshot { + bytes: vec![1, 2, 3], + exit_code: Some(0), + }); +} + +#[test] +fn round_trips_query_dto_families() { + round_trip(&PathEntry { + rel_path: "src/lib.rs".into(), + basename: "lib.rs".into(), + parent: "src".into(), + is_dir: false, + }); + round_trip(&RecentDir { + path: PathBuf::from("/tmp/project"), + last_active_ms: 12, + threads: vec![ExternalThread { + source: SourceTool::CodexCli, + file: PathBuf::from("/tmp/session.jsonl"), + external_id: "codex:1".into(), + title_hint: Some("Title".into()), + last_active_ms: 11, + }], + }); + round_trip(&GitDiffResult { + texts: vec![GitFileText { + old: Some("old".into()), + new: Some("new".into()), + }], + ..GitDiffResult::default() + }); +} + +#[test] +fn client_message_ndjson_loopback() { + let message = ClientMessage { + id: 42, + payload: ClientPayload::Query(Query::SecretPresence { + profile_id: "claude".into(), + name: "ANTHROPIC_API_KEY".into(), + }), + }; + let line = encode_line(&message).unwrap(); + assert!(line.ends_with('\n')); + let decoded = decode_client_line(&line).unwrap(); + match decoded.payload { + ClientPayload::Query(Query::SecretPresence { profile_id, name }) => { + assert_eq!(decoded.id, 42); + assert_eq!(profile_id, "claude"); + assert_eq!(name, "ANTHROPIC_API_KEY"); + } + other => panic!("unexpected payload: {other:?}"), + } +} + +#[test] +fn unknown_command_becomes_protocol_error_without_panicking() { + let json = r#"{"id":7,"payload":{"type":"command","content":{"type":"future_command","content":{"value":1}}}}"#; + let result = std::panic::catch_unwind(|| decode_client_line(json)); + let error = result.expect("decoder must not panic").unwrap_err(); + assert_eq!(error.code, "decode_error"); + assert!(error.message.contains("unknown variant")); +} diff --git a/crates/protocol/src/wire.rs b/crates/protocol/src/wire.rs new file mode 100644 index 000000000..e51c95bc5 --- /dev/null +++ b/crates/protocol/src/wire.rs @@ -0,0 +1,127 @@ +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +use crate::{Command, EventEnvelope, Hello, HelloAck, Query, QueryResponse, Topic}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtocolError { + pub code: String, + pub message: String, +} + +impl ProtocolError { + pub fn decode(message: impl Into) -> Self { + Self { + code: "decode_error".to_string(), + message: message.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ClientMessage { + pub id: u64, + pub payload: ClientPayload, +} + +#[non_exhaustive] +#[allow(clippy::large_enum_variant)] // Boxing would complicate the public contract. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum ClientPayload { + Command(Command), + Query(Query), + Subscribe(Subscription), + Unsubscribe(Subscription), + Hello(Hello), + ReverseResponse(ReverseResponse), +} + +#[non_exhaustive] +#[allow(clippy::large_enum_variant)] // Wire messages favor a direct typed API. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "snake_case")] +pub enum HostMessage { + Ack { + id: u64, + result: Result<(), ProtocolError>, + }, + QueryResult { + id: u64, + result: Result, + }, + Event(EventEnvelope), + ReverseRequest { + id: u64, + request: ReverseRequest, + }, + HelloAck(HelloAck), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Subscription { + pub topic: Topic, + pub after_seq: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ReverseRequest { + pub method: String, + pub params: serde_json::Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ReverseResponse { + pub request_id: u64, + pub result: Result, +} + +/// Encode one NDJSON record, including its trailing newline. +pub fn encode_line(value: &T) -> Result { + serde_json::to_string(value) + .map(|mut line| { + line.push('\n'); + line + }) + .map_err(|error| ProtocolError { + code: "encode_error".to_string(), + message: error.to_string(), + }) +} + +/// Decode one client NDJSON record. +/// +/// Unknown variants of data-carrying enums become a structured error here +/// rather than escaping as a panic. +pub fn decode_client_line(line: &str) -> Result { + decode_line(line) +} + +/// Decode one host NDJSON record. +pub fn decode_host_line(line: &str) -> Result { + decode_line(line) +} + +fn decode_line(line: &str) -> Result { + serde_json::from_str(line.trim_end()).map_err(|error| ProtocolError::decode(error.to_string())) +} + +pub(crate) mod base64_bytes { + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD; + use serde::{Deserialize, Deserializer, Serializer, de::Error as _}; + + pub fn serialize(bytes: &[u8], serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + STANDARD.decode(&value).map_err(D::Error::custom) + } +} diff --git a/crates/runtime/src/blocking.rs b/crates/runtime/src/blocking.rs index 78eea3e97..8fe93b451 100644 --- a/crates/runtime/src/blocking.rs +++ b/crates/runtime/src/blocking.rs @@ -69,6 +69,7 @@ mod tests { "i18n", "orchestrate-mcp", "preview-mcp", + "protocol", "runtime", "services", "term", diff --git a/crates/services/src/process.rs b/crates/services/src/process.rs index 8b34e7e98..879d202d5 100644 --- a/crates/services/src/process.rs +++ b/crates/services/src/process.rs @@ -134,6 +134,7 @@ mod tests { "i18n", "orchestrate-mcp", "preview-mcp", + "protocol", "runtime", "services", "term",