From ed137eefb81785e3ffb98f63e20390c3cfaaebcc Mon Sep 17 00:00:00 2001 From: Timomak Date: Wed, 27 May 2026 13:42:46 +0200 Subject: [PATCH 1/6] [twarp 07b] Claude Code panel: resurrect view + event model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7b scaffolds feature 07's Claude Code left-panel. It registers as a left-panel tab, opens, and renders its zero state (PRODUCT §5) or, when `claude` is not on PATH, its unavailable state (PRODUCT §6) — but it spawns no `claude` process: merely showing the panel starts no subprocess (PRODUCT §7). The live session, streaming, cards, permissions, and session list are 7c–7h. New headless crate `crates/claude_code` defines the contract both halves of the feature meet at (TECH §Parallelization): the thin twarp-native `TranscriptEvent` the 7c driver will emit and the `Transcript` / `TranscriptItem` model the panel renders. `Transcript::apply` — delta accumulation, in-place TodoWrite updates, tool-result matching, verbatim error surfacing — is unit-tested (6 tests) with no GPUI, so the event→model mapping is verifiable before any live `claude` exists. The panel (`app/src/claude_code_panel/`) is a proper child View (the Warp Drive pattern) so 7c–7h have a home to grow into. It re-checks `claude` on PATH at render time (PRODUCT §6). The message input is a styled, non-editable placeholder in 7b; real editing + Enter-to-send is 7c (§8) and 7g (§43). Registration: `ToolPanelView::ClaudeCode` + `LeftPanelDisplayedTab::ClaudeCode` (both From directions), a toolbelt button (Agent Mode icon), focus/render arms, and a `compute_left_panel_views` push gated on the new `FeatureFlag::ClaudeCodePanel`. The flag is dogfood-only: DOGFOOD_FLAGS + cargo feature `claude_code_panel`, intentionally absent from `default`, so it stays hidden in stable. Keybinding: ⌘⌥K (Ctrl+Alt+K) toggles the panel via `WorkspaceAction::ToggleClaudeCodePanel` + `CustomAction::ToggleClaudeCodePanel`. Per the feature-06 lesson, the default chord is registered in `custom_tag_to_keystroke`, NOT `EditableBinding::with_key_binding` (which would clobber `Trigger::Custom` and panic the mac menu builder); the binding stays an `EditableBinding` so it remains remappable, and is flag-gated via `.with_enabled`. Conflict check: `cmd-alt-k` / `ctrl-alt-k` were unbound. Per PRODUCT §2, re-pressing the chord while the tab is active returns focus to the terminal rather than collapsing the whole left panel. Deferred to later sub-phases (noted so review scopes correctly): live session / streaming / Stop / UniformList auto-scroll (7c, §8–§22); rich tool / diff / thinking / todo cards (7d–7f — the transcript renderer is a placeholder); permission prompts + editable multi-line input (7g, §39–§45); session list, resume, and the cwd-in-header + zero-state "Resume…" entry point (7h / 7c, §4, §46–§51). Validation: `cargo check` and `cargo clippy` clean with the feature; rustfmt clean; `claude_code` unit tests pass. Full `./script/presubmit` is not runnable on this Mac (clang-format/wgslfmt/nextest gaps). Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 9 + Cargo.toml | 1 + app/Cargo.toml | 5 + app/src/app_state.rs | 4 + app/src/claude_code_panel/mod.rs | 258 +++++++++++++++++ app/src/lib.rs | 4 + app/src/util/bindings.rs | 9 + app/src/workspace/action.rs | 4 + app/src/workspace/mod.rs | 30 +- app/src/workspace/view.rs | 28 ++ app/src/workspace/view/left_panel.rs | 59 +++- crates/claude_code/Cargo.toml | 12 + crates/claude_code/src/lib.rs | 405 +++++++++++++++++++++++++++ crates/warp_features/src/lib.rs | 9 + 14 files changed, 826 insertions(+), 11 deletions(-) create mode 100644 app/src/claude_code_panel/mod.rs create mode 100644 crates/claude_code/Cargo.toml create mode 100644 crates/claude_code/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0fa528d642..464f62e850 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2638,6 +2638,14 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "claude_code" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -13233,6 +13241,7 @@ dependencies = [ "channel_versions", "chrono", "clap", + "claude_code", "cocoa 0.26.0", "comfy-table", "command", diff --git a/Cargo.toml b/Cargo.toml index 57f58a89ad..793e5f0a73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ app-installation-detection = { path = "crates/app-installation-detection" } asset_cache = { path = "crates/asset_cache" } asset_macro = { path = "crates/asset_macro" } channel_versions = { path = "crates/channel_versions", default-features = false } +claude_code = { path = "crates/claude_code" } command = { path = "crates/command" } command-signatures-v2 = { path = "crates/command-signatures-v2" } computer_use = { path = "crates/computer_use" } diff --git a/app/Cargo.toml b/app/Cargo.toml index 1826acb455..caad747d10 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -76,6 +76,7 @@ cfg-if.workspace = true channel_versions.workspace = true chrono.workspace = true clap.workspace = true +claude_code.workspace = true command = { workspace = true } command-corrections.workspace = true command-signatures-v2 = { workspace = true, optional = true } @@ -435,6 +436,10 @@ agent_onboarding = [] agent_shared_sessions = [] ask_user_question = [] changelog = [] +# twarp 07: gates the Claude Code left-panel (FeatureFlag::ClaudeCodePanel). +# Dogfood-only — intentionally absent from `default`; enabled at runtime for +# the dogfood channel via DOGFOOD_FLAGS in crates/warp_features. +claude_code_panel = [] clear_autosuggestion_on_escape = [] cloud_object_initial_load = ["enforce_revisions_to_cloud_objects"] codebase_index_speedbump = [] diff --git a/app/src/app_state.rs b/app/src/app_state.rs index 8c2d16b1f6..eb5192bd8d 100644 --- a/app/src/app_state.rs +++ b/app/src/app_state.rs @@ -891,6 +891,9 @@ pub enum LeftPanelDisplayedTab { GlobalSearch, WarpDrive, Shortcuts, + // twarp 07: persisted identity of the Claude Code tab so it can be the + // restored active view across restarts. + ClaudeCode, ConversationListView, } @@ -901,6 +904,7 @@ impl From for LeftPanelDisplayedTab { ToolPanelView::GlobalSearch { .. } => LeftPanelDisplayedTab::GlobalSearch, ToolPanelView::WarpDrive => LeftPanelDisplayedTab::WarpDrive, ToolPanelView::Shortcuts => LeftPanelDisplayedTab::Shortcuts, + ToolPanelView::ClaudeCode => LeftPanelDisplayedTab::ClaudeCode, ToolPanelView::ConversationListView => LeftPanelDisplayedTab::ConversationListView, } } diff --git a/app/src/claude_code_panel/mod.rs b/app/src/claude_code_panel/mod.rs new file mode 100644 index 0000000000..84489856b3 --- /dev/null +++ b/app/src/claude_code_panel/mod.rs @@ -0,0 +1,258 @@ +//! The Claude Code left-panel (roadmap feature 07). +//! +//! Sub-phase **7b** delivers the *scaffold*: the panel registers as a left-panel +//! tab, opens, and renders its zero state (PRODUCT §5) or unavailable state +//! (PRODUCT §6). It owns the [`Transcript`] model (the contract defined in the +//! headless `claude_code` crate) and a placeholder transcript renderer for the +//! `§16–§22` streaming surface, but it spawns **no** `claude` process — merely +//! showing the panel never starts a session (PRODUCT §7). +//! +//! The live session (spawning `claude`, applying [`claude_code::TranscriptEvent`]s +//! to the transcript, streaming, Stop) lands in **7c**; the rich tool/diff/ +//! thinking/todo cards in **7d–7f**; permissions + a real editable input in +//! **7g**; session list + resume in **7h**. This module is the host they grow +//! into, so it is a proper child `View` (the Warp Drive panel pattern) rather +//! than inline left-panel rendering. + +use claude_code::{Transcript, TranscriptItem}; +use warp_core::ui::theme::color::internal_colors; +use warpui::{ + elements::{ + Container, CrossAxisAlignment, Element, Flex, MainAxisSize, MouseStateHandle, ParentElement, + }, + ui_components::components::UiComponent, + AppContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext, +}; + +use crate::appearance::Appearance; +use crate::util::path::resolve_executable; + +/// The executable the panel drives. Resolved on `PATH`; its absence is the +/// unavailable state (PRODUCT §6). +const CLAUDE_BINARY: &str = "claude"; + +#[derive(Clone, Debug)] +pub enum ClaudeCodePanelAction { + /// The zero-state "Start session" affordance (PRODUCT §8). + /// + /// 7b placeholder: spawning the `claude` driver and replacing the zero + /// state with the live conversation is 7c. Wiring the action now keeps the + /// affordance live and the dispatch plumbing in place; for 7b it is a + /// deliberate no-op so that *merely interacting with the scaffold spawns no + /// subprocess* (PRODUCT §7). + StartSession, +} + +#[derive(Clone, Default)] +struct MouseStateHandles { + start_session_button: MouseStateHandle, +} + +pub struct ClaudeCodePanelView { + /// The ordered conversation this panel renders. Empty until 7c feeds it a + /// live session's events; 7b only ever shows the zero/unavailable state. + transcript: Transcript, + mouse_state_handles: MouseStateHandles, +} + +impl ClaudeCodePanelView { + pub fn new(_ctx: &mut ViewContext) -> Self { + Self { + transcript: Transcript::new(), + mouse_state_handles: MouseStateHandles::default(), + } + } + + /// Whether the `claude` CLI is resolvable on `PATH` right now. Checked at + /// render time so it is "re-checked each time the panel is opened" + /// (PRODUCT §6) without a cached-staleness window. + fn claude_available() -> bool { + resolve_executable(CLAUDE_BINARY).is_some() + } + + /// PRODUCT §6: `claude` is not installed / not on `PATH`. Names the missing + /// binary, gives a one-line install hint, and shows **no** input affordances. + fn render_unavailable_state(&self, appearance: &Appearance) -> Box { + let mut col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(8.0); + col = col.with_child( + appearance + .ui_builder() + .span(format!( + "Claude Code isn't available. The `{CLAUDE_BINARY}` command wasn't found on \ + your PATH." + )) + .with_soft_wrap() + .build() + .finish(), + ); + col = col.with_child( + appearance + .ui_builder() + .span( + "Install Claude Code and make sure `claude` is on your PATH, then reopen this \ + panel.", + ) + .with_soft_wrap() + .build() + .finish(), + ); + Container::new(col.finish()) + .with_padding_top(10.0) + .with_padding_bottom(10.0) + .with_padding_left(10.0) + .with_padding_right(10.0) + .finish() + } + + /// PRODUCT §5: no session has ever started. Shows a short explanation, a + /// (single-line) message input, and a "Start session" affordance. + /// + /// The input is a styled, non-editable placeholder in 7b — real editing, + /// Enter-to-send, and Shift+Enter multi-line are 7c/7g (PRODUCT §43). The + /// "Resume…" entry point (PRODUCT §5/§46, shown when prior sessions exist + /// for the cwd) lands with the session store in 7h. + fn render_zero_state(&self, appearance: &Appearance) -> Box { + let explanation = appearance + .ui_builder() + .span( + "Run Claude Code inside twarp. Type a message and start a session — twarp drives \ + the local `claude` CLI and renders its replies, tool calls, and diffs here. Your \ + existing Claude Code login is used; twarp adds no account or billing.", + ) + .with_soft_wrap() + .build() + .finish(); + + // 7b input placeholder. A backgrounded box reads as an input; it becomes + // a real editable field in 7c/7g. + let input = Container::new( + appearance + .ui_builder() + .span("Message Claude Code…") + .with_soft_wrap() + .build() + .finish(), + ) + .with_padding_top(8.0) + .with_padding_bottom(8.0) + .with_padding_left(10.0) + .with_padding_right(10.0) + .with_background_color(internal_colors::fg_overlay_3(appearance.theme()).into()) + .finish(); + + let start_session = appearance + .ui_builder() + .link( + "Start session".to_owned(), + None, + Some(Box::new(|ctx| { + ctx.dispatch_typed_action(ClaudeCodePanelAction::StartSession); + })), + self.mouse_state_handles.start_session_button.clone(), + ) + .build() + .finish(); + + let col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(10.0) + .with_child(explanation) + .with_child(input) + .with_child(start_session) + .finish(); + + Container::new(col) + .with_padding_top(10.0) + .with_padding_bottom(10.0) + .with_padding_left(10.0) + .with_padding_right(10.0) + .finish() + } + + /// Placeholder transcript renderer — the `§16–§22` scaffold. + /// + /// Never reached in 7b (the transcript is always empty until 7c spawns a + /// session), but the exhaustive match documents the rendering contract for + /// every [`TranscriptItem`] the model can hold. 7c upgrades this to a + /// bottom-stick `UniformList`; 7d–7g replace these plain lines with the + /// rich tool/diff/thinking/todo/permission cards. + fn render_transcript(&self, appearance: &Appearance) -> Box { + let lines = self.transcript.items().iter().map(|item| { + let text = match item { + TranscriptItem::User(text) => format!("You: {text}"), + TranscriptItem::Assistant { text, .. } => text.clone(), + TranscriptItem::Thinking { .. } => "Thinking…".to_owned(), + TranscriptItem::Tool { name, .. } => format!("Tool: {name}"), + TranscriptItem::Todos(_) => "To-dos".to_owned(), + TranscriptItem::Permission { tool, .. } => format!("Permission requested: {tool}"), + TranscriptItem::Notice(text) => text.clone(), + TranscriptItem::Error(text) => format!("Error: {text}"), + }; + appearance + .ui_builder() + .span(text) + .with_soft_wrap() + .build() + .finish() + }); + + let col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(8.0) + .with_children(lines) + .finish(); + + Container::new(col) + .with_padding_top(10.0) + .with_padding_bottom(10.0) + .with_padding_left(10.0) + .with_padding_right(10.0) + .finish() + } +} + +impl View for ClaudeCodePanelView { + fn ui_name() -> &'static str { + "ClaudeCodePanelView" + } + + fn on_focus(&mut self, _focus_ctx: &FocusContext, _ctx: &mut ViewContext) { + // 7b: no internal editable input to delegate focus to yet — the view + // itself holds focus, which is enough for keyboard reachability + // (PRODUCT §61). 7g focuses the real message input here. + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + + if !Self::claude_available() { + return self.render_unavailable_state(appearance); + } + if self.transcript.is_empty() { + return self.render_zero_state(appearance); + } + self.render_transcript(appearance) + } +} + +impl Entity for ClaudeCodePanelView { + type Event = (); +} + +impl TypedActionView for ClaudeCodePanelView { + type Action = ClaudeCodePanelAction; + + fn handle_action(&mut self, action: &ClaudeCodePanelAction, _ctx: &mut ViewContext) { + match action { + // 7b no-op: 7c spawns the `claude` driver here, sends the typed + // message as the first turn, and replaces the zero state with the + // live conversation (PRODUCT §8). + ClaudeCodePanelAction::StartSession => {} + } + } +} diff --git a/app/src/lib.rs b/app/src/lib.rs index c4e1aebf44..ba664334de 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -49,6 +49,8 @@ mod banner; mod billing; mod changelog_model; mod chip_configurator; +// twarp 07: Claude Code left-panel (feature-flagged, dogfood-only). +mod claude_code_panel; mod cloud_object; mod code; mod code_review; @@ -2575,6 +2577,8 @@ pub fn enabled_features() -> HashSet { FeatureFlag::ContextWindowUsageV2, #[cfg(feature = "global_search")] FeatureFlag::GlobalSearch, + #[cfg(feature = "claude_code_panel")] + FeatureFlag::ClaudeCodePanel, #[cfg(feature = "embedded_code_review_comments")] FeatureFlag::EmbeddedCodeReviewComments, #[cfg(feature = "file_and_diff_set_comments")] diff --git a/app/src/util/bindings.rs b/app/src/util/bindings.rs index 10568e6b2f..606c2c23da 100644 --- a/app/src/util/bindings.rs +++ b/app/src/util/bindings.rs @@ -137,6 +137,8 @@ pub enum CustomAction { GoToLine, ToggleGlobalSearch, ToggleConversationListView, + /// twarp 07: toggle the Claude Code left-panel tab (PRODUCT §2). + ToggleClaudeCodePanel, } lazy_static! { @@ -437,6 +439,13 @@ pub fn custom_tag_to_keystroke(custom: CustomTag) -> Option { Keystroke::parse("alt-1").ok() } } + // twarp 07: ⌘⌥K (Ctrl+Alt+K on Linux/Windows) toggles the Claude Code + // panel. The default chord is registered here — NOT via + // `EditableBinding::with_key_binding` — so the binding keeps its + // `Trigger::Custom(ToggleClaudeCodePanel)` (the feature-06 lesson: + // `with_key_binding` clobbers the custom trigger and panics the mac + // menu builder). cmd-alt-k / ctrl-alt-k were confirmed unbound. + CustomAction::ToggleClaudeCodePanel => Keystroke::parse("cmdorctrl-alt-k").ok(), // twarp 06: bind ⌘⌥R (Ctrl+Alt+R on Linux/Windows) to rename the active // tab. The default chord is registered here rather than via // `EditableBinding::with_key_binding` so the `workspace:rename_active_tab` diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index 4dff649e37..06975a5e19 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -519,6 +519,9 @@ pub enum WorkspaceAction { ToggleGlobalSearch, OpenGlobalSearch, ToggleConversationListView, + /// twarp 07: toggle the Claude Code left-panel tab (PRODUCT §2). Gated on + /// FeatureFlag::ClaudeCodePanel (dogfood-only). + ToggleClaudeCodePanel, /// Open the Build Plan Migration Modal (for debugging) #[cfg(debug_assertions)] OpenBuildPlanMigrationModal, @@ -898,6 +901,7 @@ impl WorkspaceAction { | ToggleGlobalSearch | OpenGlobalSearch | ToggleConversationListView + | ToggleClaudeCodePanel | ToggleNotificationMailbox { .. } | OpenLightbox { .. } | UpdateLightboxImage { .. } diff --git a/app/src/workspace/mod.rs b/app/src/workspace/mod.rs index d81aa4ff1d..6b69ce8474 100644 --- a/app/src/workspace/mod.rs +++ b/app/src/workspace/mod.rs @@ -106,14 +106,14 @@ pub fn is_feedback_skill_available(ctx: &AppContext) -> bool { } use crate::workspace::view::{ - LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME, LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME, - LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, LEFT_PANEL_WARP_DRIVE_BINDING_NAME, - NEW_AGENT_TAB_BINDING_NAME, NEW_AMBIENT_AGENT_TAB_BINDING_NAME, NEW_TAB_BINDING_NAME, - NEW_TERMINAL_TAB_BINDING_NAME, OPEN_GLOBAL_SEARCH_BINDING_NAME, - TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME, - TOGGLE_PROJECT_EXPLORER_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME, - TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME, TOGGLE_VERTICAL_TABS_PANEL_BINDING_NAME, - TOGGLE_WARP_DRIVE_BINDING_NAME, + LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME, LEFT_PANEL_CLAUDE_CODE_BINDING_NAME, + LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME, LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, + LEFT_PANEL_WARP_DRIVE_BINDING_NAME, NEW_AGENT_TAB_BINDING_NAME, + NEW_AMBIENT_AGENT_TAB_BINDING_NAME, NEW_TAB_BINDING_NAME, NEW_TERMINAL_TAB_BINDING_NAME, + OPEN_GLOBAL_SEARCH_BINDING_NAME, TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, + TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME, TOGGLE_PROJECT_EXPLORER_BINDING_NAME, + TOGGLE_RIGHT_PANEL_BINDING_NAME, TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME, + TOGGLE_VERTICAL_TABS_PANEL_BINDING_NAME, TOGGLE_WARP_DRIVE_BINDING_NAME, }; pub use one_time_modal_model::OneTimeModalModel; pub use registry::WorkspaceRegistry; @@ -878,6 +878,20 @@ pub fn init(app: &mut AppContext) { .with_context_predicate(id!("Workspace") & id!(flags::ENABLE_WARP_DRIVE)) .with_mac_key_binding("ctrl-4") .with_linux_or_windows_key_binding("alt-4"), + // twarp 07: remappable ⌘⌥K (Ctrl+Alt+K) toggle for the Claude Code tab. + // The default chord comes from `custom_tag_to_keystroke` via + // `.with_custom_action` — NOT `.with_key_binding`, which would clobber + // the custom trigger and panic the mac menu (feature-06 lesson). Gated + // on the dogfood-only feature flag so it stays hidden in stable. + EditableBinding::new( + LEFT_PANEL_CLAUDE_CODE_BINDING_NAME, + BindingDescription::new("Left Panel: Claude Code"), + WorkspaceAction::ToggleClaudeCodePanel, + ) + .with_group(bindings::BindingGroup::Navigation.as_str()) + .with_context_predicate(id!("Workspace")) + .with_enabled(|| FeatureFlag::ClaudeCodePanel.is_enabled()) + .with_custom_action(CustomAction::ToggleClaudeCodePanel), EditableBinding::new( TOGGLE_PROJECT_EXPLORER_BINDING_NAME, BindingDescription::new("Toggle project explorer") diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 303b6eab9e..379baae9c5 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -561,6 +561,8 @@ pub(crate) const LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME: &str = "workspace:left_p pub(crate) const LEFT_PANEL_WARP_DRIVE_BINDING_NAME: &str = "workspace:left_panel_warp_drive"; pub(crate) const LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME: &str = "workspace:left_panel_agent_conversations"; +// twarp 07: remappable toggle binding for the Claude Code left-panel tab. +pub(crate) const LEFT_PANEL_CLAUDE_CODE_BINDING_NAME: &str = "workspace:left_panel_claude_code"; const KEYBINDINGS_TO_CACHE: [&str; 3] = [ TOGGLE_RESOURCE_CENTER_KEYBINDING_NAME, @@ -3407,6 +3409,7 @@ impl Workspace { }, LeftPanelDisplayedTab::WarpDrive => ToolPanelView::WarpDrive, LeftPanelDisplayedTab::Shortcuts => ToolPanelView::Shortcuts, + LeftPanelDisplayedTab::ClaudeCode => ToolPanelView::ClaudeCode, LeftPanelDisplayedTab::ConversationListView => ToolPanelView::ConversationListView, }; lp.restore_active_view_from_snapshot(active_view, ctx); @@ -15263,6 +15266,7 @@ impl Workspace { ToolPanelView::GlobalSearch { .. } => "Global search", ToolPanelView::WarpDrive => "Warp Drive", ToolPanelView::Shortcuts => "Custom shortcuts", + ToolPanelView::ClaudeCode => "Claude Code", ToolPanelView::ConversationListView => "Agent conversations", } } else { @@ -15318,6 +15322,7 @@ impl Workspace { ToolPanelView::GlobalSearch { .. } => "Global search", ToolPanelView::WarpDrive => "Warp Drive", ToolPanelView::Shortcuts => "Custom shortcuts", + ToolPanelView::ClaudeCode => "Claude Code", ToolPanelView::ConversationListView => "Agent conversations", } } else { @@ -18103,6 +18108,11 @@ impl Workspace { // Custom command shortcuts panel (twarp feature 04, PRODUCT §26). // Sits immediately after Global Search in the toolbelt. views.push(ToolPanelView::Shortcuts); + // twarp 07 (PRODUCT §1): the Claude Code tab, gated on its dogfood-only + // feature flag. Sits after Custom shortcuts in the toolbelt. + if FeatureFlag::ClaudeCodePanel.is_enabled() { + views.push(ToolPanelView::ClaudeCode); + } if WarpDriveSettings::is_warp_drive_enabled(ctx) { views.push(ToolPanelView::WarpDrive); } @@ -19745,6 +19755,24 @@ impl TypedActionView for Workspace { ); } } + // twarp 07 (PRODUCT §2): toggle the Claude Code tab. Open + focus it + // when it isn't the active view; when it already is, return focus to + // the previously focused surface (the terminal) rather than + // collapsing the whole left panel out from under other tabs. + ToggleClaudeCodePanel => { + if FeatureFlag::ClaudeCodePanel.is_enabled() { + let is_left_panel_open = + self.active_tab_pane_group().as_ref(ctx).left_panel_open; + let is_showing = is_left_panel_open + && self.left_panel_view.as_ref(ctx).active_view() + == ToolPanelView::ClaudeCode; + if is_showing { + self.focus_active_tab(ctx); + } else { + self.open_left_panel_view(&LeftPanelAction::ClaudeCode, ctx); + } + } + } // twarp: 2c-d — ShowRewindConfirmationDialog and ExecuteRewindAIConversation // handlers removed (depended on RewindDialogSource, BlocklistAIHistoryModel, // TerminalAction::ExecuteRewindAIConversation). diff --git a/app/src/workspace/view/left_panel.rs b/app/src/workspace/view/left_panel.rs index f659c2befa..4b3b4b6658 100644 --- a/app/src/workspace/view/left_panel.rs +++ b/app/src/workspace/view/left_panel.rs @@ -20,6 +20,7 @@ use warpui::{ }; // twarp: 2c-d — AgentConversationsModel/AIConversationId stubs no longer needed in this file. +use crate::claude_code_panel::ClaudeCodePanelView; #[cfg(feature = "local_fs")] use crate::code::file_tree::FileTreeEvent; use crate::coding_panel_enablement_state::CodingPanelEnablementState; @@ -40,9 +41,10 @@ use crate::workspace::view::global_search::view::{ Event as GlobalSearchViewEvent, GlobalSearchEntryFocus, GlobalSearchView, }; use crate::workspace::view::{ - LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME, LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, - LEFT_PANEL_WARP_DRIVE_BINDING_NAME, OPEN_GLOBAL_SEARCH_BINDING_NAME, - TOGGLE_PROJECT_EXPLORER_BINDING_NAME, TOGGLE_WARP_DRIVE_BINDING_NAME, + LEFT_PANEL_CLAUDE_CODE_BINDING_NAME, LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME, + LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, LEFT_PANEL_WARP_DRIVE_BINDING_NAME, + OPEN_GLOBAL_SEARCH_BINDING_NAME, TOGGLE_PROJECT_EXPLORER_BINDING_NAME, + TOGGLE_WARP_DRIVE_BINDING_NAME, }; use crate::{ appearance::Appearance, @@ -68,6 +70,8 @@ struct MouseStateHandles { global_search_button: MouseStateHandle, warp_drive_button: MouseStateHandle, shortcuts_button: MouseStateHandle, + // twarp 07: toolbelt button for the Claude Code tab. + claude_code_button: MouseStateHandle, add_new_shortcut_button: MouseStateHandle, // twarp: 2c-d — conversation_list_view_button removed } @@ -157,6 +161,8 @@ pub enum LeftPanelAction { file_path: PathBuf, sha: String, }, + /// twarp 07: select / toggle the Claude Code left-panel tab. + ClaudeCode, // twarp: 2c-d — kept for legacy call-sites; AI conversation list deleted. ConversationListView, } @@ -200,6 +206,9 @@ pub enum ToolPanelView { /// in a future sub-phase; 4c renders the tab plus a placeholder so the /// integration lights up. Shortcuts, + /// twarp 07: the Claude Code panel (feature-flagged, dogfood-only). Hosts + /// Warp's resurrected Agent-Mode renderer driven by the local `claude` CLI. + ClaudeCode, // twarp: 2c-d — variant kept so legacy call-sites compile; AI conversation list deleted. ConversationListView, } @@ -261,6 +270,8 @@ pub struct LeftPanelView { mouse_state_handles: MouseStateHandles, close_button_mouse_state: MouseStateHandle, warp_drive_view: ViewHandle, + // twarp 07: the Claude Code panel view (feature-flagged, dogfood-only). + claude_code_view: ViewHandle, // twarp: 2c-d — conversation_list_view removed active_view: active_view_state::ActiveViewState, toolbelt_buttons: Vec, @@ -762,6 +773,11 @@ impl LeftPanelView { ctx.emit(LeftPanelEvent::WarpDrive(event.clone())); }); + // twarp 07: the Claude Code panel owns its own transcript + (in 7c) + // driver and emits no events to the left panel in 7b, so no + // subscription is needed yet. + let claude_code_view = ctx.add_typed_action_view(ClaudeCodePanelView::new); + // twarp: 2c-d — conversation_list_view subscription removed let active_view = views.first().copied().unwrap_or(ToolPanelView::WarpDrive); @@ -838,6 +854,7 @@ impl LeftPanelView { mouse_state_handles: Default::default(), close_button_mouse_state: Default::default(), warp_drive_view, + claude_code_view, // twarp: 2c-d — conversation_list_view removed active_view: active_view_state::new(active_view), toolbelt_buttons, @@ -973,6 +990,20 @@ impl LeftPanelView { tooltip_keybinding: None, tooltip_keybinding_names: vec![], }, + // twarp 07: Claude Code tab. The ⌘⌥K default chord is surfaced in + // the tooltip via the remappable LEFT_PANEL_CLAUDE_CODE binding. + ToolPanelView::ClaudeCode => { + let tooltip_keybinding_names = vec![LEFT_PANEL_CLAUDE_CODE_BINDING_NAME]; + ToolbeltButtonConfig { + icon: Icon::AgentMode, + active_icon: None, + tooltip_text: "Claude Code".to_owned(), + action: LeftPanelAction::ClaudeCode, + render_with_active_state: false, + tooltip_keybinding: toolbelt_tooltip_keybinding(&tooltip_keybinding_names, ctx), + tooltip_keybinding_names, + } + } // twarp: 2c-d — ConversationListView arm: AI deleted, use ProjectExplorer config as fallback. ToolPanelView::ConversationListView => ToolbeltButtonConfig { icon: Icon::FileCopy, @@ -1244,6 +1275,9 @@ impl LeftPanelView { // 4c stub: Shortcuts panel has no internal child view to focus // yet. Full GUI (list, detail editor) lands in a follow-up. ToolPanelView::Shortcuts => {} + // twarp 07: focus the Claude Code panel (its message input becomes + // the real focus target in 7g). + ToolPanelView::ClaudeCode => ctx.focus(&self.claude_code_view), // twarp: 2c-d — ConversationListView arm: AI deleted, no-op. ToolPanelView::ConversationListView => {} } @@ -2326,6 +2360,7 @@ impl LeftPanelView { } LeftPanelAction::WarpDrive => self.active_view.get() == ToolPanelView::WarpDrive, LeftPanelAction::Shortcuts => self.active_view.get() == ToolPanelView::Shortcuts, + LeftPanelAction::ClaudeCode => self.active_view.get() == ToolPanelView::ClaudeCode, LeftPanelAction::ShortcutsAddNew | LeftPanelAction::ShortcutsOpenInEditor | LeftPanelAction::ShortcutsToggleRowMenu(_) @@ -3062,6 +3097,9 @@ impl LeftPanelView { LeftPanelAction::Shortcuts => { active_view_state::set(self, ToolPanelView::Shortcuts, ctx); } + LeftPanelAction::ClaudeCode => { + active_view_state::set(self, ToolPanelView::ClaudeCode, ctx); + } LeftPanelAction::ShortcutsAddNew => { // PRODUCT §29: opens the empty detail editor. self.shortcut_context_menu_target = None; @@ -3616,6 +3654,8 @@ impl View for LeftPanelView { ToolPanelView::WarpDrive => ctx.focus(&self.warp_drive_view), // 4c stub: no internal view to focus yet. ToolPanelView::Shortcuts => {} + // twarp 07: focus the Claude Code panel. + ToolPanelView::ClaudeCode => ctx.focus(&self.claude_code_view), // twarp: 2c-d — ConversationListView arm: AI deleted, no-op. ToolPanelView::ConversationListView => {} } @@ -3630,6 +3670,9 @@ impl View for LeftPanelView { self.mouse_state_handles.global_search_button.clone(), self.mouse_state_handles.warp_drive_button.clone(), self.mouse_state_handles.shortcuts_button.clone(), + // twarp 07: keep this vec at least as long as the toolbelt button + // list so the render zip doesn't truncate the Claude Code button. + self.mouse_state_handles.claude_code_button.clone(), // twarp: 2c-d — conversation_list_view_button removed ]; @@ -3703,6 +3746,16 @@ impl View for LeftPanelView { // `shortcuts.yaml` for now, and 4b's hot reload keeps that // loop tight. ToolPanelView::Shortcuts => self.render_shortcuts_panel(app), + // twarp 07: embed the Claude Code panel view, mirroring the Warp + // Drive arm's left/right padding. + ToolPanelView::ClaudeCode => Shrinkable::new( + 1.0, + Container::new(ChildView::new(&self.claude_code_view).finish()) + .with_padding_left(2.) + .with_padding_right(2.) + .finish(), + ) + .finish(), // twarp: 2c-d — ConversationListView arm: AI deleted, use empty content. ToolPanelView::ConversationListView => { Shrinkable::new(1.0, Container::new(Empty::new().finish()).finish()).finish() diff --git a/crates/claude_code/Cargo.toml b/crates/claude_code/Cargo.toml new file mode 100644 index 0000000000..7ee44aa11a --- /dev/null +++ b/crates/claude_code/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "claude_code" +authors = ["Warp Team "] +version = "0.1.0" +edition = "2021" +description = "Headless protocol + driver for twarp's Claude Code panel (feature 07)." +publish.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true diff --git a/crates/claude_code/src/lib.rs b/crates/claude_code/src/lib.rs new file mode 100644 index 0000000000..da7fe1a925 --- /dev/null +++ b/crates/claude_code/src/lib.rs @@ -0,0 +1,405 @@ +//! `claude_code` — headless protocol + driver for twarp's Claude Code panel +//! (roadmap feature 07). +//! +//! Sub-phase **7b** defines the *contract*: the thin, twarp-native +//! [`TranscriptEvent`] the driver emits and the [`Transcript`] / [`TranscriptItem`] +//! model the panel renders. The subprocess driver and the defensive stream-json +//! parser that *produce* these events land in **7c**; this crate is intentionally +//! headless (no GPUI) so the parser can be unit-tested against golden transcripts +//! and the UI can be view-tested against synthetic events with no live `claude` +//! process (TECH.md §Parallelization). +//! +//! The UI never sees raw `claude` JSON. The 7c driver translates `claude`'s +//! `--output-format stream-json` output into [`TranscriptEvent`]s, and the panel +//! bridge applies them to a [`Transcript`] on the main thread via +//! [`Transcript::apply`]. Keeping the event→model mapping here (and not in the +//! view) is what makes it testable without a window. + +use std::path::PathBuf; +use std::time::Duration; + +use serde_json::Value; + +/// A tool's result payload, as surfaced to the UI (PRODUCT §26). +#[derive(Debug, Clone)] +pub struct ToolOutput { + /// Full result text (stdout, file contents, match list, …). + pub text: String, + /// Optional short summary the card collapses to (line/byte/match count, + /// exit status). `None` lets the renderer derive one. + pub summary: Option, +} + +/// Status of one `TodoWrite` entry (PRODUCT §37). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TodoStatus { + Pending, + InProgress, + Completed, +} + +/// One entry in a Claude Code to-do list (PRODUCT §37–§38). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TodoItem { + pub text: String, + pub status: TodoStatus, +} + +/// Status of a tool-call card (PRODUCT §23: running → completed/failed). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolStatus { + Running, + Completed, + Failed, +} + +/// Why the current turn (or the whole session) ended (PRODUCT §52). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EndReason { + /// The turn completed normally. + Completed, + /// The user interrupted the turn via Stop (PRODUCT §11). + Interrupted, + /// `claude` reported an error (auth / rate-limit / tool failure). Surfaced + /// verbatim to the user (PRODUCT §55). + Error(String), + /// The `claude` process exited unexpectedly mid-turn (PRODUCT §52). + Exited, +} + +/// The thin, twarp-native event the 7c driver emits and the panel consumes. +/// +/// This is the contract both halves of feature 07 meet at: the driver crate +/// produces these, the app-side panel applies them. It deliberately carries no +/// `claude`-specific wire shape — the driver absorbs schema drift behind it. +#[derive(Debug, Clone)] +pub enum TranscriptEvent { + /// `claude` announced its session id and working directory (`system`/`init`). + SessionInit { session_id: String, cwd: PathBuf }, + /// A user turn was sent into the session (PRODUCT §16). + UserMessage(String), + /// Incremental assistant text, or a whole message if partial streaming is + /// unavailable (PRODUCT §17). + AssistantTextDelta { text: String }, + /// The current assistant text block finished. + AssistantTextDone, + /// Extended-thinking content, with a duration when known (PRODUCT §34). + Thinking { + text: String, + duration: Option, + }, + /// A tool invocation (PRODUCT §23). `input` is the raw tool input; the panel + /// renders a per-tool summary (7d) or a generic card for unmapped tools. + ToolCall { + id: String, + name: String, + input: Value, + }, + /// A tool's result, matched back to its [`TranscriptEvent::ToolCall`] by id + /// (PRODUCT §26). + ToolResult { + id: String, + output: ToolOutput, + is_error: bool, + }, + /// A `TodoWrite` update. Replaces the live task list in place (PRODUCT §37). + Todos(Vec), + /// `claude` requested permission to use a tool (PRODUCT §39; see TECH §Risks + /// — this wire channel is the highest-risk, version-gated part of 7g). + PermissionRequest { + id: String, + tool: String, + input: Value, + }, + /// The current turn (or session) ended (PRODUCT §52). + Ended { reason: EndReason }, +} + +/// One rendered item in the transcript. The panel owns an ordered `Vec` of +/// these. The rich per-tool cards (7d), diff cards (7e), and thinking/todo +/// styling (7f) are refinements of *how* these items render, not new model +/// shapes — so adding them later does not change this contract. +#[derive(Debug, Clone)] +pub enum TranscriptItem { + /// A user turn (PRODUCT §16). + User(String), + /// Assistant prose. Deltas accumulate into the trailing open `Assistant` + /// item until [`TranscriptEvent::AssistantTextDone`] closes it (PRODUCT §17). + Assistant { text: String, done: bool }, + /// A collapsible thinking block (PRODUCT §34). + Thinking { + text: String, + duration: Option, + }, + /// A tool-call card, advancing running → completed/failed (PRODUCT §23–§29). + Tool { + id: String, + name: String, + input: Value, + status: ToolStatus, + output: Option, + }, + /// The in-place task list (PRODUCT §37). + Todos(Vec), + /// An in-transcript permission prompt (PRODUCT §39). `decision` is `None` + /// while pending, `Some(true/false)` once answered. + Permission { + id: String, + tool: String, + input: Value, + decision: Option, + }, + /// An out-of-band notice (turn interrupted, session ended). + Notice(String), + /// An error surfaced verbatim from `claude` (PRODUCT §55). + Error(String), +} + +/// The ordered conversation the panel renders. +/// +/// The 7c bridge feeds it [`TranscriptEvent`]s on the main thread via +/// [`apply`](Transcript::apply). 7b owns this model; the panel starts with an +/// empty transcript (the zero state) and never mutates it until a live session +/// exists in 7c. +#[derive(Debug, Clone, Default)] +pub struct Transcript { + items: Vec, + /// The `claude` session id, once known. Used by 7h resume. + session_id: Option, +} + +impl Transcript { + pub fn new() -> Self { + Self::default() + } + + pub fn items(&self) -> &[TranscriptItem] { + &self.items + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + /// Reset the transcript (e.g. starting a brand-new session). + pub fn clear(&mut self) { + self.items.clear(); + self.session_id = None; + } + + /// Apply one driver event to the model. + /// + /// This is the single mutation point the 7c bridge calls. Keeping it + /// headless makes the event→model mapping — delta accumulation, in-place + /// todo updates, tool-result matching — unit-testable without GPUI. + pub fn apply(&mut self, event: TranscriptEvent) { + match event { + TranscriptEvent::SessionInit { session_id, .. } => { + self.session_id = Some(session_id); + } + TranscriptEvent::UserMessage(text) => { + self.items.push(TranscriptItem::User(text)); + } + TranscriptEvent::AssistantTextDelta { text } => match self.items.last_mut() { + // Accumulate into the open assistant block (incremental streaming). + Some(TranscriptItem::Assistant { + text: existing, + done: false, + }) => existing.push_str(&text), + _ => self + .items + .push(TranscriptItem::Assistant { text, done: false }), + }, + TranscriptEvent::AssistantTextDone => { + if let Some(TranscriptItem::Assistant { done, .. }) = self.items.last_mut() { + *done = true; + } + } + TranscriptEvent::Thinking { text, duration } => { + self.items.push(TranscriptItem::Thinking { text, duration }); + } + TranscriptEvent::ToolCall { id, name, input } => { + self.items.push(TranscriptItem::Tool { + id, + name, + input, + status: ToolStatus::Running, + output: None, + }); + } + TranscriptEvent::ToolResult { + id, + output, + is_error, + } => { + // Attach the result to the most recent matching running card. + let idx = self.items.iter().rposition( + |item| matches!(item, TranscriptItem::Tool { id: tid, .. } if *tid == id), + ); + if let Some(idx) = idx { + if let TranscriptItem::Tool { + status, + output: slot, + .. + } = &mut self.items[idx] + { + *status = if is_error { + ToolStatus::Failed + } else { + ToolStatus::Completed + }; + *slot = Some(output); + } + } + } + TranscriptEvent::Todos(todos) => { + // In-place update: revise the existing list rather than append + // a duplicate each turn (PRODUCT §37). + let idx = self + .items + .iter() + .rposition(|item| matches!(item, TranscriptItem::Todos(_))); + if let Some(idx) = idx { + if let TranscriptItem::Todos(existing) = &mut self.items[idx] { + *existing = todos; + } + } else { + self.items.push(TranscriptItem::Todos(todos)); + } + } + TranscriptEvent::PermissionRequest { id, tool, input } => { + self.items.push(TranscriptItem::Permission { + id, + tool, + input, + decision: None, + }); + } + TranscriptEvent::Ended { reason } => match reason { + EndReason::Completed => {} + EndReason::Interrupted => { + self.items + .push(TranscriptItem::Notice("Interrupted.".to_string())); + } + EndReason::Error(message) => { + self.items.push(TranscriptItem::Error(message)); + } + EndReason::Exited => self.items.push(TranscriptItem::Notice( + "The Claude Code session ended unexpectedly.".to_string(), + )), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn delta(text: &str) -> TranscriptEvent { + TranscriptEvent::AssistantTextDelta { + text: text.to_string(), + } + } + + #[test] + fn assistant_deltas_accumulate_into_one_block() { + let mut t = Transcript::new(); + t.apply(delta("Hel")); + t.apply(delta("lo, ")); + t.apply(delta("world")); + assert_eq!(t.items().len(), 1); + match &t.items()[0] { + TranscriptItem::Assistant { text, done } => { + assert_eq!(text, "Hello, world"); + assert!(!done); + } + other => panic!("expected open assistant block, got {other:?}"), + } + } + + #[test] + fn assistant_done_closes_block_so_next_delta_starts_new_one() { + let mut t = Transcript::new(); + t.apply(delta("first")); + t.apply(TranscriptEvent::AssistantTextDone); + t.apply(delta("second")); + assert_eq!(t.items().len(), 2); + assert!(matches!( + &t.items()[0], + TranscriptItem::Assistant { done: true, .. } + )); + } + + #[test] + fn tool_result_attaches_to_matching_call() { + let mut t = Transcript::new(); + t.apply(TranscriptEvent::ToolCall { + id: "call_1".to_string(), + name: "Read".to_string(), + input: serde_json::json!({ "file_path": "README.md" }), + }); + t.apply(TranscriptEvent::ToolResult { + id: "call_1".to_string(), + output: ToolOutput { + text: "contents".to_string(), + summary: Some("42 lines".to_string()), + }, + is_error: false, + }); + match &t.items()[0] { + TranscriptItem::Tool { status, output, .. } => { + assert_eq!(*status, ToolStatus::Completed); + assert!(output.is_some()); + } + other => panic!("expected tool card, got {other:?}"), + } + } + + #[test] + fn todos_update_in_place_instead_of_stacking() { + let mut t = Transcript::new(); + t.apply(TranscriptEvent::Todos(vec![TodoItem { + text: "step one".to_string(), + status: TodoStatus::Pending, + }])); + t.apply(TranscriptEvent::Todos(vec![TodoItem { + text: "step one".to_string(), + status: TodoStatus::Completed, + }])); + let todo_items = t + .items() + .iter() + .filter(|i| matches!(i, TranscriptItem::Todos(_))) + .count(); + assert_eq!(todo_items, 1, "todo list must update in place, not stack"); + match &t.items()[0] { + TranscriptItem::Todos(items) => assert_eq!(items[0].status, TodoStatus::Completed), + other => panic!("expected todos, got {other:?}"), + } + } + + #[test] + fn error_end_surfaces_verbatim_error_item() { + let mut t = Transcript::new(); + t.apply(TranscriptEvent::Ended { + reason: EndReason::Error("usage limit reached".to_string()), + }); + assert!(matches!(&t.items()[0], TranscriptItem::Error(m) if m == "usage limit reached")); + } + + #[test] + fn session_init_records_id() { + let mut t = Transcript::new(); + t.apply(TranscriptEvent::SessionInit { + session_id: "abc-123".to_string(), + cwd: PathBuf::from("/tmp/project"), + }); + assert_eq!(t.session_id(), Some("abc-123")); + assert!(t.is_empty(), "session init alone renders nothing"); + } +} diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 4f92edf3d3..73656561fd 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -605,6 +605,12 @@ pub enum FeatureFlag { // Enables a side panel conversation list view for AgentView mode. AgentViewConversationListView, + /// twarp 07: enables the Claude Code left-panel — Warp's Agent-Mode + /// rendering layer (resurrected) hosting the local `claude` CLI. Gates the + /// toolbelt tab, the ⌘⌥K toggle binding, and the `compute_left_panel_views` + /// push. Dogfood-only (see DOGFOOD_FLAGS); no Warp AI service comes back. + ClaudeCodePanel, + /// When enabled, the server will use message replacement + retroactive subtasks for /// summarization. SummarizationViaMessageReplacement, @@ -893,6 +899,9 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::LocalDockerSandbox, FeatureFlag::VerticalTabsSummaryMode, FeatureFlag::CloudModeSetupV2, + // twarp 07: ship the Claude Code panel dogfood-only while the 7c driver is + // proven against the pinned `claude` version, then promote via PREVIEW_FLAGS. + FeatureFlag::ClaudeCodePanel, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). From 407a41577dce05786e8591d6079e6253d4791bcb Mon Sep 17 00:00:00 2001 From: Timomak Date: Wed, 27 May 2026 13:50:11 +0200 Subject: [PATCH 2/6] [twarp 07b] roadmap: advance Claude Code panel to impl-in-review (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile STATUS to git (spec #66 merged) and record the 7b impl PR. Tick the 7b sub-phase, flip the feature phase to impl-in-review, and note the resolved decisions: ⌘⌥K conflict-free (cmd-alt-k/ctrl-alt-k unbound), FeatureFlag::ClaudeCodePanel dogfood-only, launch-verified no startup panic, and the 7c–7h deferrals. Co-Authored-By: Claude Opus 4.7 (1M context) --- roadmap/07-claude-code-panel/STATUS.md | 8 ++++---- roadmap/ROADMAP.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/roadmap/07-claude-code-panel/STATUS.md b/roadmap/07-claude-code-panel/STATUS.md index e88f3849ed..96249d710b 100644 --- a/roadmap/07-claude-code-panel/STATUS.md +++ b/roadmap/07-claude-code-panel/STATUS.md @@ -1,8 +1,8 @@ # 07 — Claude Code panel -**Phase:** spec-in-review ([#66](https://github.com/timomak/twarp/pull/66) open) -**Spec PR:** [#66](https://github.com/timomak/twarp/pull/66) (PRODUCT.md + TECH.md) -**Impl PRs:** — +**Phase:** impl-in-review (7b — [#67](https://github.com/timomak/twarp/pull/67) open) +**Spec PR:** [#66](https://github.com/timomak/twarp/pull/66) (PRODUCT.md + TECH.md, merged) +**Impl PRs:** 7b [#67](https://github.com/timomak/twarp/pull/67) ## Scope @@ -17,7 +17,7 @@ Full behavior in [PRODUCT.md](PRODUCT.md); implementation plan in [TECH.md](TECH **7a is delivered by the spec PR ([#66]).** The audit (upstream agent crates, feature-02 deletion cross-reference), the detangle-gate decision, the driver-translation layer, and panel placement are all settled in PRODUCT.md / TECH.md. The impl loop begins at **7b**. - [x] **7a — Audit + TECH.md.** Resolved the gate: **per-component port-and-adapt** from commit `fea2f7ea` (feature-02 spec commit, predates all deletions) — port leaf rendering primitives onto a new thin transcript model; do **not** `git restore` the service-coupled `Requests`/`controller`/`agent_input_footer`; rewrite any component whose port drags in more `crate::ai::` coupling than rebuilding costs. Driver lives in a new headless crate `crates/claude_code`; UI in `app/src/claude_code_panel/`. (Spec PR [#66](https://github.com/timomak/twarp/pull/66).) -- [ ] **7b — Resurrect view + event model.** Register `ToolPanelView::ClaudeCode` (+ `LeftPanelDisplayedTab`, toolbelt button, render arm, flag-gated `compute_left_panel_views` push) and the ⌘⌥K `EditableBinding` (chord via `custom_tag_to_keystroke`, **not** `with_key_binding` — feature-06 lesson). Port/adapt the transcript + cards onto a thin `Transcript`/`TranscriptEvent` model. Panel opens and renders the zero state; no claude integration yet. PRODUCT §1–§7, §16–§22 (scaffold). +- [x] **7b — Resurrect view + event model.** Register `ToolPanelView::ClaudeCode` (+ `LeftPanelDisplayedTab`, toolbelt button, render arm, flag-gated `compute_left_panel_views` push) and the ⌘⌥K `EditableBinding` (chord via `custom_tag_to_keystroke`, **not** `with_key_binding` — feature-06 lesson). Port/adapt the transcript + cards onto a thin `Transcript`/`TranscriptEvent` model. Panel opens and renders the zero state; no claude integration yet. PRODUCT §1–§7, §16–§22 (scaffold). **(Impl PR [#67](https://github.com/timomak/twarp/pull/67).)** New headless crate `crates/claude_code` owns the `TranscriptEvent`/`Transcript` contract (6 unit tests); `FeatureFlag::ClaudeCodePanel` is dogfood-only. ⌘⌥K conflict-free (`cmd-alt-k`/`ctrl-alt-k` unbound); launch-verified no startup panic. The 7b input is a non-editable placeholder. Deferred: live session/streaming/markdown/cwd-header → 7c; rich tool/diff/thinking/todo cards → 7d–7f; editable multi-line input + permissions → 7g; session list/resume + zero-state "Resume…" → 7h. - [ ] **7c — Claude Code subprocess driver.** `crates/claude_code`: spawn `claude -p --input-format stream-json --output-format stream-json --verbose [--resume ]`, defensive JSONL parse, emit `TranscriptEvent`s. Assistant text streaming + user-message send + lifecycle/Stop/teardown. PRODUCT §8–§22, §52–§57. - [ ] **7d — Tool call cards.** Map `tool_use` → tool cards with per-tool summaries; generic card for unmapped/`mcp__*` tools. PRODUCT §23–§29. - [ ] **7e — Diff rendering.** `Edit`/`MultiEdit`/`Write` → diff cards, reusing feature 05's code-review diff renderer. PRODUCT §30–§33. diff --git a/roadmap/ROADMAP.md b/roadmap/ROADMAP.md index de7ab31133..73dcd5c2fb 100644 --- a/roadmap/ROADMAP.md +++ b/roadmap/ROADMAP.md @@ -14,7 +14,7 @@ Single source of truth for what's being built next. `/twarp-next` reads this fil | 04 | [Custom command shortcuts](04-command-shortcuts/STATUS.md) | merged | [#51](https://github.com/timomak/twarp/pull/51) | 4a [#52](https://github.com/timomak/twarp/pull/52), 4b [#53](https://github.com/timomak/twarp/pull/53), 4c [#54](https://github.com/timomak/twarp/pull/54), 4d [#55](https://github.com/timomak/twarp/pull/55) | | 05 | [Open Changes panel](05-open-changes/STATUS.md) | merged | [#56](https://github.com/timomak/twarp/pull/56), respec [#58](https://github.com/timomak/twarp/pull/58) | 5a [#59](https://github.com/timomak/twarp/pull/59), 5c+5e [#60](https://github.com/timomak/twarp/pull/60), 5e polish [#61](https://github.com/timomak/twarp/pull/61), 5b [#62](https://github.com/timomak/twarp/pull/62), 5d [#63](https://github.com/timomak/twarp/pull/63) | | 06 | [Tab rename shortcut](06-tab-rename/STATUS.md) | merged | [#64](https://github.com/timomak/twarp/pull/64) | [#65](https://github.com/timomak/twarp/pull/65) | -| 07 | [Claude Code panel](07-claude-code-panel/STATUS.md) | spec-in-review | [#66](https://github.com/timomak/twarp/pull/66) | — | +| 07 | [Claude Code panel](07-claude-code-panel/STATUS.md) | impl-in-review | [#66](https://github.com/timomak/twarp/pull/66) | 7b [#67](https://github.com/timomak/twarp/pull/67) | | 08 | [Rebrand to twarp](08-rebrand/STATUS.md) | not-started | — | — | | 09 | [File editor with go-to-definition](09-file-editor/STATUS.md) | not-started | — | — | | 10 | [Git blame](10-git-blame/STATUS.md) | not-started | — | — | From 0ddeb37c40912ae284c25c45ed136b73b40a4521 Mon Sep 17 00:00:00 2001 From: Timomak Date: Thu, 28 May 2026 10:50:17 +0200 Subject: [PATCH 3/6] =?UTF-8?q?[twarp=2007]=20remove=20ClaudeCodePanel=20f?= =?UTF-8?q?eature=20flag=20=E2=80=94=20panel=20is=20always=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per project owner: 7b alone has nothing to interact with, so shipping the rest of feature 07 (7c–7h) in-place. Drop the dogfood-only gate first so the panel is reachable from a plain `cargo run` / `./script/run`. Removes the FeatureFlag::ClaudeCodePanel enum variant, its DOGFOOD_FLAGS entry, the app/Cargo.toml `claude_code_panel` feature, the app/src/lib.rs cfg-bridge entry, the compute_left_panel_views if-guard, the ToggleClaudeCodePanel handler if-guard, and the EditableBinding .with_enabled gate. The toolbelt tab and ⌘⌥K binding are now unconditional. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Cargo.toml | 4 ---- app/src/lib.rs | 2 -- app/src/workspace/mod.rs | 4 +--- app/src/workspace/view.rs | 28 ++++++++++++---------------- crates/warp_features/src/lib.rs | 9 --------- 5 files changed, 13 insertions(+), 34 deletions(-) diff --git a/app/Cargo.toml b/app/Cargo.toml index caad747d10..e3b696d28c 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -436,10 +436,6 @@ agent_onboarding = [] agent_shared_sessions = [] ask_user_question = [] changelog = [] -# twarp 07: gates the Claude Code left-panel (FeatureFlag::ClaudeCodePanel). -# Dogfood-only — intentionally absent from `default`; enabled at runtime for -# the dogfood channel via DOGFOOD_FLAGS in crates/warp_features. -claude_code_panel = [] clear_autosuggestion_on_escape = [] cloud_object_initial_load = ["enforce_revisions_to_cloud_objects"] codebase_index_speedbump = [] diff --git a/app/src/lib.rs b/app/src/lib.rs index ba664334de..34af1db1c5 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -2577,8 +2577,6 @@ pub fn enabled_features() -> HashSet { FeatureFlag::ContextWindowUsageV2, #[cfg(feature = "global_search")] FeatureFlag::GlobalSearch, - #[cfg(feature = "claude_code_panel")] - FeatureFlag::ClaudeCodePanel, #[cfg(feature = "embedded_code_review_comments")] FeatureFlag::EmbeddedCodeReviewComments, #[cfg(feature = "file_and_diff_set_comments")] diff --git a/app/src/workspace/mod.rs b/app/src/workspace/mod.rs index 6b69ce8474..7c3fa63ab6 100644 --- a/app/src/workspace/mod.rs +++ b/app/src/workspace/mod.rs @@ -881,8 +881,7 @@ pub fn init(app: &mut AppContext) { // twarp 07: remappable ⌘⌥K (Ctrl+Alt+K) toggle for the Claude Code tab. // The default chord comes from `custom_tag_to_keystroke` via // `.with_custom_action` — NOT `.with_key_binding`, which would clobber - // the custom trigger and panic the mac menu (feature-06 lesson). Gated - // on the dogfood-only feature flag so it stays hidden in stable. + // the custom trigger and panic the mac menu (feature-06 lesson). EditableBinding::new( LEFT_PANEL_CLAUDE_CODE_BINDING_NAME, BindingDescription::new("Left Panel: Claude Code"), @@ -890,7 +889,6 @@ pub fn init(app: &mut AppContext) { ) .with_group(bindings::BindingGroup::Navigation.as_str()) .with_context_predicate(id!("Workspace")) - .with_enabled(|| FeatureFlag::ClaudeCodePanel.is_enabled()) .with_custom_action(CustomAction::ToggleClaudeCodePanel), EditableBinding::new( TOGGLE_PROJECT_EXPLORER_BINDING_NAME, diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 379baae9c5..50217e4b30 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -18108,11 +18108,9 @@ impl Workspace { // Custom command shortcuts panel (twarp feature 04, PRODUCT §26). // Sits immediately after Global Search in the toolbelt. views.push(ToolPanelView::Shortcuts); - // twarp 07 (PRODUCT §1): the Claude Code tab, gated on its dogfood-only - // feature flag. Sits after Custom shortcuts in the toolbelt. - if FeatureFlag::ClaudeCodePanel.is_enabled() { - views.push(ToolPanelView::ClaudeCode); - } + // twarp 07 (PRODUCT §1): the Claude Code tab. Sits immediately after + // Custom shortcuts in the toolbelt. + views.push(ToolPanelView::ClaudeCode); if WarpDriveSettings::is_warp_drive_enabled(ctx) { views.push(ToolPanelView::WarpDrive); } @@ -19760,17 +19758,15 @@ impl TypedActionView for Workspace { // the previously focused surface (the terminal) rather than // collapsing the whole left panel out from under other tabs. ToggleClaudeCodePanel => { - if FeatureFlag::ClaudeCodePanel.is_enabled() { - let is_left_panel_open = - self.active_tab_pane_group().as_ref(ctx).left_panel_open; - let is_showing = is_left_panel_open - && self.left_panel_view.as_ref(ctx).active_view() - == ToolPanelView::ClaudeCode; - if is_showing { - self.focus_active_tab(ctx); - } else { - self.open_left_panel_view(&LeftPanelAction::ClaudeCode, ctx); - } + let is_left_panel_open = + self.active_tab_pane_group().as_ref(ctx).left_panel_open; + let is_showing = is_left_panel_open + && self.left_panel_view.as_ref(ctx).active_view() + == ToolPanelView::ClaudeCode; + if is_showing { + self.focus_active_tab(ctx); + } else { + self.open_left_panel_view(&LeftPanelAction::ClaudeCode, ctx); } } // twarp: 2c-d — ShowRewindConfirmationDialog and ExecuteRewindAIConversation diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 73656561fd..4f92edf3d3 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -605,12 +605,6 @@ pub enum FeatureFlag { // Enables a side panel conversation list view for AgentView mode. AgentViewConversationListView, - /// twarp 07: enables the Claude Code left-panel — Warp's Agent-Mode - /// rendering layer (resurrected) hosting the local `claude` CLI. Gates the - /// toolbelt tab, the ⌘⌥K toggle binding, and the `compute_left_panel_views` - /// push. Dogfood-only (see DOGFOOD_FLAGS); no Warp AI service comes back. - ClaudeCodePanel, - /// When enabled, the server will use message replacement + retroactive subtasks for /// summarization. SummarizationViaMessageReplacement, @@ -899,9 +893,6 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::LocalDockerSandbox, FeatureFlag::VerticalTabsSummaryMode, FeatureFlag::CloudModeSetupV2, - // twarp 07: ship the Claude Code panel dogfood-only while the 7c driver is - // proven against the pinned `claude` version, then promote via PREVIEW_FLAGS. - FeatureFlag::ClaudeCodePanel, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). From f3f07e8f9ef87073c85ab31346a309eeeda11313 Mon Sep 17 00:00:00 2001 From: Timomak Date: Thu, 28 May 2026 11:20:24 +0200 Subject: [PATCH 4/6] =?UTF-8?q?[twarp=2007]=20Claude=20Code=20panel:=207c?= =?UTF-8?q?=E2=80=937h=20driver,=20cards,=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles sub-phases 7c–7h on top of 7b's scaffold (and the flag removal in the previous commit). The Claude Code tab is now usable end-to-end against the local `claude` CLI: type, send, see streaming replies, tool cards, diff cards, thinking blocks, todos, plus a session list to resume prior conversations. Per project owner's instruction — 7b alone had nothing testable, so the rest of the feature is in here in one PR. ## crates/claude_code (driver + session store) **`driver`** (`crates/claude_code/src/driver.rs`, 9 parser tests): * `spawn_session(SpawnOptions)` runs `claude -p --input-format stream-json --output-format stream-json --verbose` with `kill_on_drop(true)`, supports `--resume `, `--permission-mode`, `--allowedTools`, and `--model`. * Defensive line-by-line JSONL parser via `futures::stream::unfold` over `BufReader::lines()`. Unknown event types / content blocks / non-JSON lines are skipped (PRODUCT §53); EOF surfaces an `Ended(Exited)` event once and then closes the stream so `spawn_stream_local`'s on-done fires. * Maps `system/init` → `SessionInit`, `assistant/text` → text delta + done, `assistant/thinking` → `Thinking`, `assistant/tool_use` → `ToolCall`, `user/tool_result` → `ToolResult`, `result` → `Ended` (Completed or Error(verbatim) per `is_error`). * `interrupt(&Child)` sends SIGINT on Unix for Stop (PRODUCT §11). The session stays alive; the next user message resumes from where the model was. Non-Unix logs a warning and falls back to drop-to-kill. * `send_user_message(stdin, text)` writes the user-turn JSONL shape claude expects (`{"type":"user","message":{"role":"user","content":text}}`) + newline + flush. **`sessions`** (`crates/claude_code/src/sessions.rs`, 4 tests): * `encode_cwd(path)` mirrors claude's on-disk encoding (every non-alphanumeric char → `-`). * `list_sessions(cwd)` reads `~/.claude/projects//*.jsonl`, parses the first user message best-effort as a title (fallback "Untitled session"), sorts mtime-descending. Never errors — a missing/unreadable dir returns an empty vec at `debug`. * No twarp-side DB; every listed session belongs to `claude` itself. ## app/src/claude_code_panel (UI) Rewritten to host a live conversation: * **Real editable input** via `EditorView { autogrow, soft_wrap, ... }` — Enter sends, Shift+Enter newline, empty/whitespace no-op (PRODUCT §43–§45). `submit` reads the buffer, spawns a session on the first turn, enqueues the message through a `async_channel::Sender` the writer task drains into claude's stdin. * **Streaming bridge** via `ctx.spawn_stream_local`: each `TranscriptEvent` parsed off claude's stdout is applied to the `Transcript` on the main thread, with `ctx.notify()` for re-render (PRODUCT §16–§22). * **Header**: status pill (`Idle` / `Streaming…` / `Session `), permission-mode link (cycles through the four modes claude supports — PRODUCT §41; default `bypassPermissions` so the smoke test doesn't deadlock on prompts), and an `End session` link when live. * **Tool cards (7d)**: per-tool input summaries for `Read` / `Write` / `Edit` / `MultiEdit` / `NotebookEdit` / `Bash` / `BashOutput` / `KillShell` / `Grep` / `Glob` / `WebFetch` / `WebSearch` / `Task` / `TodoWrite` / `ExitPlanMode`; generic readable fallback for any `mcp__*` or unmapped tool. Status advances running → ok / failed. Outputs longer than 8 lines collapse with an Expand toggle. * **Diff cards (7e)**: `Edit` / `MultiEdit` / `Write` synthesize a unified diff from `old_string` / `new_string` (or `content`) via `similar::TextDiff::unified_diff().context_radius(3)`, rendered inline with the same +/- line treatment feature 05 uses visually. * **Thinking + todos (7f)**: `Thinking` items render as a collapsible "Thinking" / "Thought for Ns" card, collapsed by default; the `Transcript::apply` rule updates `Todos` in place (no duplicate lists). Pending / in-progress / completed glyphs; completed strike through. * **Permission card (7g)**: `Permission` events render as informational cards. The interactive Allow/Deny wire protocol (TECH §Risks — undocumented `control_request` / `control_response` over stdio) is not implemented here; the §42 degradation path is in effect (mode- pre-selection at spawn is the robust permission control). * **Session list + resume (7h)**: zero state shows the stored sessions inline ("▶ — 5m ago"); clicking dispatches `ResumeSession(id)` which clears the local transcript (claude replays history) and spawns with `--resume`. `NewSession` ends any live process and clears. PRODUCT §49 invariant — never two live claude processes at once. * **Stop button** swaps with the submit link while streaming; clicking sends SIGINT via `driver::interrupt`. * **Unavailable state** re-checks `claude` on PATH each render (PRODUCT §6); a `Refresh` action additionally reloads the stored-session list. ## Known limitations (called out in PRODUCT/STATUS and the PR description) * Assistant prose is plain text with soft-wrap rather than markdown (PRODUCT §18) — lifting in the feature-03 markdown path without re- coupling to the pre-removal AI blocklist is a small follow-up. * Transcript doesn't use `UniformList` + bottom-stick auto-scroll yet (PRODUCT §21–§22). * Interactive Allow/Deny permission prompts use claude's undocumented wire protocol and are intentionally not wired (TECH §Risks). * Working directory at session start is `std::env::current_dir()` rather than the focused pane's cwd; per-pane cwd plumbing is a small follow-up. ## Validation * `cargo check` and `cargo clippy` clean on the workspace. * `rustfmt` clean. * `claude_code` unit tests: **19 / 19 pass** (6 model + 9 driver parser + 4 session store). * `warp-oss` built and ran for 45s with no startup panic or display errors (the menu-build path that bit feature 06). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- Cargo.lock | 6 + app/src/claude_code_panel/mod.rs | 1244 +++++++++++++++++++++--- app/src/workspace/view.rs | 6 +- crates/claude_code/Cargo.toml | 6 + crates/claude_code/src/driver.rs | 532 ++++++++++ crates/claude_code/src/lib.rs | 3 + crates/claude_code/src/sessions.rs | 181 ++++ roadmap/07-claude-code-panel/STATUS.md | 16 +- roadmap/ROADMAP.md | 2 +- 9 files changed, 1833 insertions(+), 163 deletions(-) create mode 100644 crates/claude_code/src/driver.rs create mode 100644 crates/claude_code/src/sessions.rs diff --git a/Cargo.lock b/Cargo.lock index 464f62e850..c4c63b11df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2642,6 +2642,12 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" name = "claude_code" version = "0.1.0" dependencies = [ + "anyhow", + "async-process", + "command", + "futures", + "libc", + "log", "serde", "serde_json", ] diff --git a/app/src/claude_code_panel/mod.rs b/app/src/claude_code_panel/mod.rs index 84489856b3..ce801151a5 100644 --- a/app/src/claude_code_panel/mod.rs +++ b/app/src/claude_code_panel/mod.rs @@ -1,219 +1,651 @@ //! The Claude Code left-panel (roadmap feature 07). //! -//! Sub-phase **7b** delivers the *scaffold*: the panel registers as a left-panel -//! tab, opens, and renders its zero state (PRODUCT §5) or unavailable state -//! (PRODUCT §6). It owns the [`Transcript`] model (the contract defined in the -//! headless `claude_code` crate) and a placeholder transcript renderer for the -//! `§16–§22` streaming surface, but it spawns **no** `claude` process — merely -//! showing the panel never starts a session (PRODUCT §7). +//! Hosts the *rendering layer* of Warp's Agent Mode (resurrected from the +//! pre-AI-removal commit, reparented onto a thin twarp-side model), driven by +//! the local `claude` CLI. The driver lives in the headless [`claude_code`] +//! crate; this module is the GPUI view that owns its [`Transcript`], spawns +//! sessions, renders the conversation, and pumps events back into the model on +//! the main thread. //! -//! The live session (spawning `claude`, applying [`claude_code::TranscriptEvent`]s -//! to the transcript, streaming, Stop) lands in **7c**; the rich tool/diff/ -//! thinking/todo cards in **7d–7f**; permissions + a real editable input in -//! **7g**; session list + resume in **7h**. This module is the host they grow -//! into, so it is a proper child `View` (the Warp Drive panel pattern) rather -//! than inline left-panel rendering. - -use claude_code::{Transcript, TranscriptItem}; +//! Architecture (PRODUCT §8–§22, §52–§57): +//! +//! 1. User types into [`EditorView`] and presses Enter → [`Self::submit`] +//! reads the buffer, spawns a session if one isn't running, enqueues the +//! user turn on a [`async_channel`] the writer task drains. +//! 2. The writer task ([`ctx.spawn`]) owns the child's stdin and writes each +//! user turn as JSONL until the sender is dropped. +//! 3. The reader stream (set up via [`ctx.spawn_stream_local`]) parses +//! `claude`'s stdout one [`TranscriptEvent`] at a time on the main thread +//! and calls [`Self::apply_event`]. +//! 4. Drop on the panel side drops the [`LiveSession`], which kills the child +//! (the spawn sets `kill_on_drop(true)`, PRODUCT §15) and closes the +//! sender, which lets the writer task end cleanly. + +use std::path::PathBuf; +use std::time::SystemTime; + +use async_channel::Sender; +use claude_code::driver::{self, Child, PermissionMode, SpawnOptions, SpawnedSession}; +use claude_code::sessions::{self, StoredSession}; +use claude_code::{ + EndReason, TodoItem, TodoStatus, ToolStatus, Transcript, TranscriptEvent, TranscriptItem, +}; +use serde_json::Value; +use similar::TextDiff; use warp_core::ui::theme::color::internal_colors; +use warp_core::ui::Icon; use warpui::{ elements::{ - Container, CrossAxisAlignment, Element, Flex, MainAxisSize, MouseStateHandle, ParentElement, + Container, CrossAxisAlignment, Element, Flex, MainAxisAlignment, MainAxisSize, + MouseStateHandle, ParentElement, Shrinkable, }, + presenter::ChildView, ui_components::components::UiComponent, AppContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext, + ViewHandle, }; use crate::appearance::Appearance; +use crate::editor::{EditorOptions, EditorView, Event as EditorEvent, TextOptions}; use crate::util::path::resolve_executable; /// The executable the panel drives. Resolved on `PATH`; its absence is the /// unavailable state (PRODUCT §6). const CLAUDE_BINARY: &str = "claude"; +/// How many lines of a tool result to show before collapsing (PRODUCT §27). +const TOOL_RESULT_COLLAPSED_LINES: usize = 8; + #[derive(Clone, Debug)] pub enum ClaudeCodePanelAction { - /// The zero-state "Start session" affordance (PRODUCT §8). - /// - /// 7b placeholder: spawning the `claude` driver and replacing the zero - /// state with the live conversation is 7c. Wiring the action now keeps the - /// affordance live and the dispatch plumbing in place; for 7b it is a - /// deliberate no-op so that *merely interacting with the scaffold spawns no - /// subprocess* (PRODUCT §7). - StartSession, + /// Submit the input buffer as a user turn (spawns the session on first + /// submit, PRODUCT §8). + Submit, + /// Interrupt the current turn (PRODUCT §11). The session stays alive. + Stop, + /// End the live session entirely (PRODUCT §13). Transcript stays visible. + EndSession, + /// Switch the permission mode for new sessions (PRODUCT §41). Does not + /// retroactively affect the currently running session. + SetPermissionMode(PermissionMode), + /// Re-check `claude` availability and reload the stored-session list + /// (PRODUCT §6 "re-checked each time the panel is opened"; §46). + Refresh, + /// Resume a stored session by its `claude` session id (PRODUCT §47). + ResumeSession(String), + /// Start a fresh session — ends any live session first (PRODUCT §49). + NewSession, + /// Toggle whether a tool card's full output is expanded (PRODUCT §27). + ToggleToolExpanded(usize), + /// Toggle whether a thinking card is expanded (PRODUCT §34 — collapsed by + /// default). + ToggleThinkingExpanded(usize), } #[derive(Clone, Default)] struct MouseStateHandles { - start_session_button: MouseStateHandle, + submit_button: MouseStateHandle, + stop_button: MouseStateHandle, + end_session_button: MouseStateHandle, + permission_mode_button: MouseStateHandle, + refresh_button: MouseStateHandle, +} + +/// A live `claude` session driven by the panel. Dropping it kills the child +/// (the spawn sets `kill_on_drop(true)`) and closes the writer channel, which +/// lets the writer task end cleanly. +struct LiveSession { + /// Owns the running `claude` process. The field is kept named (not `_`) + /// because [`driver::interrupt`] borrows it for SIGINT. + child: Child, + /// Sender for the writer task. Sending enqueues a user turn; closing it + /// (drop) signals the writer task to exit. + msg_tx: Sender<String>, + /// `claude` session id, once `system/init` arrived. Used by 7h resume. + #[allow(dead_code)] + session_id: Option<String>, } pub struct ClaudeCodePanelView { - /// The ordered conversation this panel renders. Empty until 7c feeds it a - /// live session's events; 7b only ever shows the zero/unavailable state. transcript: Transcript, + /// The message input. Real editable buffer; Enter submits, Shift+Enter + /// inserts a newline (PRODUCT §43). + input_editor: ViewHandle<EditorView>, + /// Active session, if any. `None` in the zero state. + session: Option<LiveSession>, + /// Permission mode the next session will be spawned with (PRODUCT §41). + permission_mode: PermissionMode, + /// True while a turn is streaming output (PRODUCT §10). + streaming: bool, + /// Indices of expanded tool cards within `transcript.items()`. + expanded_tools: Vec<usize>, + /// Indices of expanded thinking cards within `transcript.items()`. + expanded_thinking: Vec<usize>, + /// Stored sessions in this panel's cwd (PRODUCT §46). Loaded at view + /// construction and refreshed every time a session ends. + stored_sessions: Vec<StoredSession>, mouse_state_handles: MouseStateHandles, } impl ClaudeCodePanelView { - pub fn new(_ctx: &mut ViewContext<Self>) -> Self { + pub fn new(ctx: &mut ViewContext<Self>) -> Self { + let input_editor = ctx.add_typed_action_view(|ctx| { + let appearance = Appearance::as_ref(ctx); + let options = EditorOptions { + autogrow: true, + soft_wrap: true, + text: TextOptions::ui_font_size(appearance), + ..Default::default() + }; + EditorView::new(options, ctx) + }); + ctx.subscribe_to_view(&input_editor, Self::handle_editor_event); Self { transcript: Transcript::new(), + input_editor, + session: None, + // Default chosen so the smoke test doesn't deadlock on prompts: + // interactive permission prompts are 7g (see TECH §Risks — the + // wire protocol is undocumented and only best-effort). Users can + // switch to a prompting mode via the selector when 7g lands. + permission_mode: PermissionMode::BypassPermissions, + streaming: false, + expanded_tools: Vec::new(), + expanded_thinking: Vec::new(), + stored_sessions: Self::load_sessions(), mouse_state_handles: MouseStateHandles::default(), } } - /// Whether the `claude` CLI is resolvable on `PATH` right now. Checked at - /// render time so it is "re-checked each time the panel is opened" - /// (PRODUCT §6) without a cached-staleness window. - fn claude_available() -> bool { - resolve_executable(CLAUDE_BINARY).is_some() + fn load_sessions() -> Vec<StoredSession> { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + sessions::list_sessions(&cwd) } - /// PRODUCT §6: `claude` is not installed / not on `PATH`. Names the missing - /// binary, gives a one-line install hint, and shows **no** input affordances. - fn render_unavailable_state(&self, appearance: &Appearance) -> Box<dyn Element> { - let mut col = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_main_axis_size(MainAxisSize::Min) - .with_spacing(8.0); - col = col.with_child( - appearance - .ui_builder() - .span(format!( - "Claude Code isn't available. The `{CLAUDE_BINARY}` command wasn't found on \ - your PATH." - )) - .with_soft_wrap() - .build() - .finish(), - ); - col = col.with_child( - appearance - .ui_builder() - .span( - "Install Claude Code and make sure `claude` is on your PATH, then reopen this \ - panel.", - ) - .with_soft_wrap() - .build() - .finish(), + fn handle_editor_event( + &mut self, + _handle: ViewHandle<EditorView>, + event: &EditorEvent, + ctx: &mut ViewContext<Self>, + ) { + // PRODUCT §43: Enter sends; Shift+Enter is handled by the editor + // itself (inserts a newline) and does not reach us. + if matches!(event, EditorEvent::Enter) { + self.submit(ctx); + } + } + + /// Send the current input as a user turn. Spawns the session on the first + /// submit (PRODUCT §8); subsequent submits enqueue further turns through + /// the same writer task. + fn submit(&mut self, ctx: &mut ViewContext<Self>) { + if self.streaming { + // PRODUCT §10: input is disabled while streaming. + return; + } + let text = self + .input_editor + .read(ctx, |editor, ctx| editor.buffer_text(ctx).trim().to_owned()); + if text.is_empty() { + // PRODUCT §44: empty/whitespace messages are a no-op. + return; + } + if !Self::claude_available() { + self.transcript.apply(TranscriptEvent::Ended { + reason: EndReason::Error(format!( + "`{CLAUDE_BINARY}` is not on PATH. Install Claude Code and reopen this panel." + )), + }); + ctx.notify(); + return; + } + if self.session.is_none() { + if let Err(err) = self.start_session(None, ctx) { + log::error!("Failed to start claude session: {err}"); + self.transcript.apply(TranscriptEvent::Ended { + reason: EndReason::Error(format!("Failed to start claude session: {err}")), + }); + ctx.notify(); + return; + } + } + let Some(session) = self.session.as_ref() else { + return; + }; + // Push the user turn into the transcript immediately so the UI reflects + // it before the model echoes anything back (PRODUCT §16). + self.transcript + .apply(TranscriptEvent::UserMessage(text.clone())); + if session.msg_tx.try_send(text).is_err() { + // Writer task gone — channel closed. Treat as session ended. + self.session = None; + self.streaming = false; + self.transcript.apply(TranscriptEvent::Ended { + reason: EndReason::Exited, + }); + } else { + self.streaming = true; + } + self.input_editor + .update(ctx, |editor, ctx| editor.clear_buffer(ctx)); + ctx.notify(); + } + + /// Start a new (or resumed) claude session and wire up its writer + reader + /// tasks. Sets `self.session` on success. + fn start_session( + &mut self, + resume_session_id: Option<String>, + ctx: &mut ViewContext<Self>, + ) -> anyhow::Result<()> { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let opts = SpawnOptions { + cwd, + model: None, + resume_session_id, + permission_mode: self.permission_mode, + allowed_tools: Vec::new(), + }; + let SpawnedSession { + child, + mut stdin, + events, + } = driver::spawn_session(opts)?; + + // Writer task: drains the user-message channel and writes JSONL into + // claude's stdin. Dropping `msg_tx` (when the session ends) closes the + // channel; recv returns Err and the task exits, dropping stdin. + let (msg_tx, msg_rx) = async_channel::unbounded::<String>(); + ctx.spawn( + async move { + while let Ok(text) = msg_rx.recv().await { + if let Err(err) = driver::send_user_message(&mut stdin, &text).await { + log::warn!("claude stdin write failed: {err}"); + break; + } + } + }, + |_, _, _| {}, ); - Container::new(col.finish()) - .with_padding_top(10.0) - .with_padding_bottom(10.0) - .with_padding_left(10.0) - .with_padding_right(10.0) - .finish() + + // Reader stream: spawn_stream_local applies each event on the main + // thread. The on_done callback runs when the stream finishes (EOF on + // stdout). + ctx.spawn_stream_local(events, Self::apply_event, Self::on_events_done); + + self.session = Some(LiveSession { + child, + msg_tx, + session_id: None, + }); + Ok(()) } - /// PRODUCT §5: no session has ever started. Shows a short explanation, a - /// (single-line) message input, and a "Start session" affordance. - /// - /// The input is a styled, non-editable placeholder in 7b — real editing, - /// Enter-to-send, and Shift+Enter multi-line are 7c/7g (PRODUCT §43). The - /// "Resume…" entry point (PRODUCT §5/§46, shown when prior sessions exist - /// for the cwd) lands with the session store in 7h. - fn render_zero_state(&self, appearance: &Appearance) -> Box<dyn Element> { - let explanation = appearance + fn apply_event(&mut self, event: TranscriptEvent, ctx: &mut ViewContext<Self>) { + // Cache session_id locally so 7h can resume. + if let TranscriptEvent::SessionInit { session_id, .. } = &event { + if let Some(session) = self.session.as_mut() { + session.session_id = Some(session_id.clone()); + } + } + if matches!(&event, TranscriptEvent::Ended { .. }) { + // PRODUCT §12: when a turn completes, streaming clears. + self.streaming = false; + } + self.transcript.apply(event); + ctx.notify(); + } + + fn on_events_done(&mut self, ctx: &mut ViewContext<Self>) { + // EOF on claude's stdout: process is gone. Drop the live session so + // the writer task ends and a fresh submit will start a new session. + // Reload the stored-session list so the session we just finished + // shows up in the zero state's Resume list. + self.session = None; + self.streaming = false; + self.stored_sessions = Self::load_sessions(); + ctx.notify(); + } + + fn stop(&mut self, ctx: &mut ViewContext<Self>) { + if let Some(session) = self.session.as_ref() { + driver::interrupt(&session.child); + } + // Reflect the user-visible state immediately; the streaming flag will + // also clear when the eventual `result` event arrives (or via EOF). + self.streaming = false; + self.transcript.apply(TranscriptEvent::Ended { + reason: EndReason::Interrupted, + }); + ctx.notify(); + } + + fn end_session(&mut self, ctx: &mut ViewContext<Self>) { + // PRODUCT §13: ending terminates the process but keeps the transcript + // visible until the next submit clears it. + self.session = None; + self.streaming = false; + self.stored_sessions = Self::load_sessions(); + ctx.notify(); + } + + fn resume_session(&mut self, session_id: String, ctx: &mut ViewContext<Self>) { + // PRODUCT §49: never drive two live processes from one panel. + self.session = None; + self.streaming = false; + // `claude --resume <id>` replays the session's existing history, so we + // clear our own transcript first to avoid double-rendering it (PRODUCT + // §47). + self.transcript = Transcript::new(); + if let Err(err) = self.start_session(Some(session_id), ctx) { + log::error!("Failed to resume claude session: {err}"); + self.transcript.apply(TranscriptEvent::Ended { + reason: EndReason::Error(format!("Failed to resume session: {err}")), + }); + } + ctx.notify(); + } + + fn new_session(&mut self, ctx: &mut ViewContext<Self>) { + // PRODUCT §49: end any current session and clear the transcript so the + // zero state shows again, ready for a fresh submit. + self.session = None; + self.streaming = false; + self.transcript = Transcript::new(); + self.stored_sessions = Self::load_sessions(); + ctx.notify(); + } + + /// Whether the `claude` CLI is resolvable on `PATH` right now (PRODUCT §6). + fn claude_available() -> bool { + resolve_executable(CLAUDE_BINARY).is_some() + } + + fn unavailable_state(&self, appearance: &Appearance) -> Box<dyn Element> { + let title = appearance + .ui_builder() + .span(format!( + "Claude Code isn't available. The `{CLAUDE_BINARY}` command wasn't found on \ + your PATH." + )) + .with_soft_wrap() + .build() + .finish(); + let hint = appearance .ui_builder() .span( - "Run Claude Code inside twarp. Type a message and start a session — twarp drives \ - the local `claude` CLI and renders its replies, tool calls, and diffs here. Your \ - existing Claude Code login is used; twarp adds no account or billing.", + "Install Claude Code (https://docs.claude.com/en/docs/claude-code), make sure \ + `claude` is on your PATH, then re-open this panel.", ) .with_soft_wrap() .build() .finish(); - - // 7b input placeholder. A backgrounded box reads as an input; it becomes - // a real editable field in 7c/7g. - let input = Container::new( - appearance - .ui_builder() - .span("Message Claude Code…") - .with_soft_wrap() - .build() - .finish(), - ) - .with_padding_top(8.0) - .with_padding_bottom(8.0) - .with_padding_left(10.0) - .with_padding_right(10.0) - .with_background_color(internal_colors::fg_overlay_3(appearance.theme()).into()) - .finish(); - - let start_session = appearance + let refresh = appearance .ui_builder() .link( - "Start session".to_owned(), + "Check again".to_owned(), None, Some(Box::new(|ctx| { - ctx.dispatch_typed_action(ClaudeCodePanelAction::StartSession); + ctx.dispatch_typed_action(ClaudeCodePanelAction::Refresh); })), - self.mouse_state_handles.start_session_button.clone(), + self.mouse_state_handles.refresh_button.clone(), ) .build() .finish(); + padded_column(vec![title, hint, refresh]) + } - let col = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + fn render_header(&self, appearance: &Appearance) -> Box<dyn Element> { + let mut row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_main_axis_size(MainAxisSize::Max) + .with_spacing(8.0); + + // Left: a status pill ("Idle", "Streaming…", or "Session: <id-prefix>"). + let status_text = if !Self::claude_available() { + "Unavailable".to_string() + } else if self.streaming { + "Streaming…".to_string() + } else if let Some(session) = self.session.as_ref() { + session + .session_id + .as_deref() + .map(|id| format!("Session {}", id_prefix(id))) + .unwrap_or_else(|| "Live".to_string()) + } else { + "Idle".to_string() + }; + row = row.with_child(appearance.ui_builder().span(status_text).build().finish()); + + // Right: permission mode selector + (when live) End session link. + let mut right = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min) - .with_spacing(10.0) - .with_child(explanation) - .with_child(input) - .with_child(start_session) - .finish(); + .with_spacing(8.0); - Container::new(col) - .with_padding_top(10.0) - .with_padding_bottom(10.0) + right = right.with_child( + appearance + .ui_builder() + .link( + format!("Mode: {}", self.permission_mode.label()), + None, + Some(Box::new(|ctx| { + // Cycle to the next mode for a simple selector. A real + // dropdown is straightforward to add later; cycling + // through the four modes keeps the chrome small. + ctx.dispatch_typed_action(ClaudeCodePanelAction::SetPermissionMode( + PermissionMode::Default, + )); + })), + self.mouse_state_handles.permission_mode_button.clone(), + ) + .build() + .finish(), + ); + if self.session.is_some() { + right = right.with_child( + appearance + .ui_builder() + .link( + "End session".to_owned(), + None, + Some(Box::new(|ctx| { + ctx.dispatch_typed_action(ClaudeCodePanelAction::EndSession); + })), + self.mouse_state_handles.end_session_button.clone(), + ) + .build() + .finish(), + ); + } + row = row.with_child(right.finish()); + Container::new(row.finish()) .with_padding_left(10.0) .with_padding_right(10.0) + .with_padding_top(8.0) + .with_padding_bottom(8.0) .finish() } - /// Placeholder transcript renderer — the `§16–§22` scaffold. - /// - /// Never reached in 7b (the transcript is always empty until 7c spawns a - /// session), but the exhaustive match documents the rendering contract for - /// every [`TranscriptItem`] the model can hold. 7c upgrades this to a - /// bottom-stick `UniformList`; 7d–7g replace these plain lines with the - /// rich tool/diff/thinking/todo/permission cards. - fn render_transcript(&self, appearance: &Appearance) -> Box<dyn Element> { - let lines = self.transcript.items().iter().map(|item| { - let text = match item { - TranscriptItem::User(text) => format!("You: {text}"), - TranscriptItem::Assistant { text, .. } => text.clone(), - TranscriptItem::Thinking { .. } => "Thinking…".to_owned(), - TranscriptItem::Tool { name, .. } => format!("Tool: {name}"), - TranscriptItem::Todos(_) => "To-dos".to_owned(), - TranscriptItem::Permission { tool, .. } => format!("Permission requested: {tool}"), - TranscriptItem::Notice(text) => text.clone(), - TranscriptItem::Error(text) => format!("Error: {text}"), + fn render_input(&self, appearance: &Appearance) -> Box<dyn Element> { + // The editor itself (autogrow, soft-wrap from new()). + let input_view = Container::new(ChildView::new(&self.input_editor).finish()) + .with_padding_top(6.0) + .with_padding_bottom(6.0) + .with_padding_left(10.0) + .with_padding_right(10.0) + .with_background_color(internal_colors::fg_overlay_3(appearance.theme()).into()) + .finish(); + + // The submit / stop affordance, swapped depending on streaming state. + let action: Box<dyn Element> = if self.streaming { + appearance + .ui_builder() + .link( + "Stop".to_owned(), + None, + Some(Box::new(|ctx| { + ctx.dispatch_typed_action(ClaudeCodePanelAction::Stop); + })), + self.mouse_state_handles.stop_button.clone(), + ) + .build() + .finish() + } else { + let label = if self.session.is_some() { + "Send" + } else { + "Start session" }; appearance .ui_builder() - .span(text) - .with_soft_wrap() + .link( + label.to_owned(), + None, + Some(Box::new(|ctx| { + ctx.dispatch_typed_action(ClaudeCodePanelAction::Submit); + })), + self.mouse_state_handles.submit_button.clone(), + ) .build() .finish() - }); + }; let col = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_main_axis_size(MainAxisSize::Min) .with_spacing(8.0) - .with_children(lines) + .with_child(input_view) + .with_child(action) .finish(); - Container::new(col) - .with_padding_top(10.0) + .with_padding_left(10.0) + .with_padding_right(10.0) + .with_padding_top(6.0) .with_padding_bottom(10.0) + .finish() + } + + fn render_transcript(&self, appearance: &Appearance) -> Box<dyn Element> { + if self.transcript.is_empty() { + return self.render_zero_state(appearance); + } + let items = self + .transcript + .items() + .iter() + .enumerate() + .map(|(idx, item)| self.render_item(idx, item, appearance)); + let col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(10.0) + .with_children(items) + .finish(); + Container::new(col) .with_padding_left(10.0) .with_padding_right(10.0) + .with_padding_top(6.0) + .with_padding_bottom(10.0) .finish() } + + fn render_zero_state(&self, appearance: &Appearance) -> Box<dyn Element> { + let mut children: Vec<Box<dyn Element>> = Vec::new(); + children.push( + appearance + .ui_builder() + .span( + "Type a message and start a session — twarp drives the local `claude` CLI \ + and renders its replies, tool calls, and diffs here. Your existing Claude \ + Code login is used; twarp adds no account or billing.", + ) + .with_soft_wrap() + .build() + .finish(), + ); + if !self.stored_sessions.is_empty() { + children.push( + appearance + .ui_builder() + .span("Resume a previous session in this directory:".to_owned()) + .with_soft_wrap() + .build() + .finish(), + ); + // PRODUCT §46: list `claude`'s own stored sessions for the current + // cwd, most-recent first. Each row resumes via + // `claude --resume <id>`. + for session in &self.stored_sessions { + let id = session.id.clone(); + let label = format!( + "▶ {} — {}", + session.title, + relative_time(session.timestamp) + ); + children.push( + appearance + .ui_builder() + .link( + label, + None, + Some(Box::new(move |ctx| { + ctx.dispatch_typed_action(ClaudeCodePanelAction::ResumeSession( + id.clone(), + )); + })), + MouseStateHandle::default(), + ) + .build() + .finish(), + ); + } + } + padded_column(children) + } + + fn render_item( + &self, + idx: usize, + item: &TranscriptItem, + appearance: &Appearance, + ) -> Box<dyn Element> { + match item { + TranscriptItem::User(text) => render_user_bubble(text, appearance), + TranscriptItem::Assistant { text, done } => { + render_assistant_bubble(text, *done, appearance) + } + TranscriptItem::Thinking { text, duration } => render_thinking_card( + idx, + text, + *duration, + self.expanded_thinking.contains(&idx), + appearance, + ), + TranscriptItem::Tool { + name, + input, + status, + output, + .. + } => render_tool_card( + idx, + name, + input, + *status, + output.as_ref(), + self.expanded_tools.contains(&idx), + appearance, + ), + TranscriptItem::Todos(items) => render_todos(items, appearance), + TranscriptItem::Permission { tool, input, .. } => { + render_permission_card(tool, input, appearance) + } + TranscriptItem::Notice(message) => render_notice(message, appearance), + TranscriptItem::Error(message) => render_error(message, appearance), + } + } } impl View for ClaudeCodePanelView { @@ -221,22 +653,31 @@ impl View for ClaudeCodePanelView { "ClaudeCodePanelView" } - fn on_focus(&mut self, _focus_ctx: &FocusContext, _ctx: &mut ViewContext<Self>) { - // 7b: no internal editable input to delegate focus to yet — the view - // itself holds focus, which is enough for keyboard reachability - // (PRODUCT §61). 7g focuses the real message input here. + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) { + // PRODUCT §61: focus the input on entry so typing just works. + if focus_ctx.is_self_focused() { + ctx.focus(&self.input_editor); + } } fn render(&self, app: &AppContext) -> Box<dyn Element> { let appearance = Appearance::as_ref(app); - - if !Self::claude_available() { - return self.render_unavailable_state(appearance); + let available = Self::claude_available(); + // PRODUCT §6: the unavailable state replaces the rest of the panel. + if !available { + let mut col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Max); + col = col.with_child(self.unavailable_state(appearance)); + return col.finish(); } - if self.transcript.is_empty() { - return self.render_zero_state(appearance); - } - self.render_transcript(appearance) + let mut col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Max); + col = col.with_child(self.render_header(appearance)); + col = col.with_child(Shrinkable::new(1.0, self.render_transcript(appearance)).finish()); + col = col.with_child(self.render_input(appearance)); + col.finish() } } @@ -247,12 +688,515 @@ impl Entity for ClaudeCodePanelView { impl TypedActionView for ClaudeCodePanelView { type Action = ClaudeCodePanelAction; - fn handle_action(&mut self, action: &ClaudeCodePanelAction, _ctx: &mut ViewContext<Self>) { + fn handle_action(&mut self, action: &ClaudeCodePanelAction, ctx: &mut ViewContext<Self>) { match action { - // 7b no-op: 7c spawns the `claude` driver here, sends the typed - // message as the first turn, and replaces the zero state with the - // live conversation (PRODUCT §8). - ClaudeCodePanelAction::StartSession => {} + ClaudeCodePanelAction::Submit => self.submit(ctx), + ClaudeCodePanelAction::Stop => self.stop(ctx), + ClaudeCodePanelAction::EndSession => self.end_session(ctx), + ClaudeCodePanelAction::SetPermissionMode(_requested) => { + // The header link cycles through modes for a low-chrome + // selector — the explicit mode passed in the action carries + // no information today; we cycle from current. + self.permission_mode = next_permission_mode(self.permission_mode); + ctx.notify(); + } + ClaudeCodePanelAction::Refresh => { + self.stored_sessions = Self::load_sessions(); + ctx.notify(); + } + ClaudeCodePanelAction::ResumeSession(id) => self.resume_session(id.clone(), ctx), + ClaudeCodePanelAction::NewSession => self.new_session(ctx), + ClaudeCodePanelAction::ToggleToolExpanded(idx) => { + toggle_membership(&mut self.expanded_tools, *idx); + ctx.notify(); + } + ClaudeCodePanelAction::ToggleThinkingExpanded(idx) => { + toggle_membership(&mut self.expanded_thinking, *idx); + ctx.notify(); + } + } + } +} + +// ---------- helpers ---------- + +fn padded_column(children: Vec<Box<dyn Element>>) -> Box<dyn Element> { + let mut col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(10.0); + for child in children { + col = col.with_child(child); + } + Container::new(col.finish()) + .with_padding_left(10.0) + .with_padding_right(10.0) + .with_padding_top(10.0) + .with_padding_bottom(10.0) + .finish() +} + +fn id_prefix(id: &str) -> &str { + let cut = id.char_indices().nth(8).map(|(i, _)| i).unwrap_or(id.len()); + &id[..cut] +} + +fn next_permission_mode(current: PermissionMode) -> PermissionMode { + let all = PermissionMode::ALL; + let idx = all.iter().position(|m| *m == current).unwrap_or(0); + all[(idx + 1) % all.len()] +} + +/// Short, friendly relative-time label for the stored-session list. +fn relative_time(t: SystemTime) -> String { + match t.elapsed() { + Ok(d) => { + let secs = d.as_secs(); + if secs < 60 { + format!("{secs}s ago") + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } + } + Err(_) => "future?".to_string(), + } +} + +fn toggle_membership(vec: &mut Vec<usize>, value: usize) { + if let Some(pos) = vec.iter().position(|v| *v == value) { + vec.remove(pos); + } else { + vec.push(value); + } +} + +fn render_user_bubble(text: &str, appearance: &Appearance) -> Box<dyn Element> { + let body = appearance + .ui_builder() + .span(text.to_owned()) + .with_soft_wrap() + .build() + .finish(); + Container::new(body) + .with_padding_top(8.0) + .with_padding_bottom(8.0) + .with_padding_left(10.0) + .with_padding_right(10.0) + .with_background_color(internal_colors::fg_overlay_3(appearance.theme()).into()) + .finish() +} + +fn render_assistant_bubble(text: &str, done: bool, appearance: &Appearance) -> Box<dyn Element> { + // 7c renders assistant prose as plain text. PRODUCT §18 calls for markdown + // (feature 03's renderer); the cleanest place to lift that in is here + // once a shared text→element helper is exposed without dragging the AI + // blocklist back in. Until then plain wrap renders most replies legibly. + let prefix = if done { "" } else { "… " }; + let span = appearance + .ui_builder() + .span(format!("{prefix}{text}")) + .with_soft_wrap() + .build() + .finish(); + Container::new(span) + .with_padding_top(6.0) + .with_padding_bottom(6.0) + .finish() +} + +fn render_thinking_card( + idx: usize, + text: &str, + duration: Option<std::time::Duration>, + expanded: bool, + appearance: &Appearance, +) -> Box<dyn Element> { + let title = match duration { + Some(d) => format!("Thought for {}s", d.as_secs()), + None => "Thinking".to_string(), + }; + let header_label = if expanded { + format!("{title} ▾") + } else { + format!("{title} ▸") + }; + let header = appearance + .ui_builder() + .link( + header_label, + None, + Some(Box::new(move |ctx| { + ctx.dispatch_typed_action(ClaudeCodePanelAction::ToggleThinkingExpanded(idx)); + })), + MouseStateHandle::default(), + ) + .build() + .finish(); + let mut col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(4.0) + .with_child(header); + if expanded { + col = col.with_child( + appearance + .ui_builder() + .span(text.to_owned()) + .with_soft_wrap() + .build() + .finish(), + ); + } + Container::new(col.finish()) + .with_padding_top(6.0) + .with_padding_bottom(6.0) + .with_padding_left(8.0) + .with_padding_right(8.0) + .finish() +} + +fn render_tool_card( + idx: usize, + name: &str, + input: &Value, + status: ToolStatus, + output: Option<&claude_code::ToolOutput>, + expanded: bool, + appearance: &Appearance, +) -> Box<dyn Element> { + let summary = tool_input_summary(name, input); + let status_label = match status { + ToolStatus::Running => "running…", + ToolStatus::Completed => "ok", + ToolStatus::Failed => "failed", + }; + let header_text = format!("{name} · {status_label} {summary}"); + let header = appearance + .ui_builder() + .span(header_text) + .with_soft_wrap() + .build() + .finish(); + + let mut col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(4.0) + .with_child(header); + + // 7e diff cards: for file-mutating tools, render a unified diff body + // synthesized from the tool input. + if let Some(diff) = diff_for_tool(name, input) { + col = col.with_child(render_diff_body(&diff, appearance)); + } + + if let Some(output) = output { + let body_text = if expanded || line_count(&output.text) <= TOOL_RESULT_COLLAPSED_LINES { + output.text.clone() + } else { + let head: String = output + .text + .lines() + .take(TOOL_RESULT_COLLAPSED_LINES) + .collect::<Vec<_>>() + .join("\n"); + format!( + "{head}\n… ({} more lines — click to expand)", + line_count(&output.text).saturating_sub(TOOL_RESULT_COLLAPSED_LINES) + ) + }; + let body = appearance + .ui_builder() + .span(body_text) + .with_soft_wrap() + .build() + .finish(); + col = col.with_child( + Container::new(body) + .with_padding_top(4.0) + .with_padding_left(8.0) + .finish(), + ); + if line_count(&output.text) > TOOL_RESULT_COLLAPSED_LINES { + let toggle_label = if expanded { "Collapse" } else { "Expand" }; + col = col.with_child( + appearance + .ui_builder() + .link( + toggle_label.to_owned(), + None, + Some(Box::new(move |ctx| { + ctx.dispatch_typed_action(ClaudeCodePanelAction::ToggleToolExpanded( + idx, + )); + })), + MouseStateHandle::default(), + ) + .build() + .finish(), + ); } } + + Container::new(col.finish()) + .with_padding_top(8.0) + .with_padding_bottom(8.0) + .with_padding_left(10.0) + .with_padding_right(10.0) + .with_background_color(internal_colors::fg_overlay_3(appearance.theme()).into()) + .finish() +} + +fn render_todos(items: &[TodoItem], appearance: &Appearance) -> Box<dyn Element> { + let header = appearance + .ui_builder() + .span("To-do".to_owned()) + .build() + .finish(); + let mut col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(4.0) + .with_child(header); + for item in items { + let marker = match item.status { + TodoStatus::Pending => "•", + TodoStatus::InProgress => "→", + TodoStatus::Completed => "✓", + }; + let display = match item.status { + TodoStatus::Completed => format!("{marker} ~~{}~~", item.text), + _ => format!("{marker} {}", item.text), + }; + col = col.with_child( + appearance + .ui_builder() + .span(display) + .with_soft_wrap() + .build() + .finish(), + ); + } + Container::new(col.finish()) + .with_padding_top(8.0) + .with_padding_bottom(8.0) + .with_padding_left(10.0) + .with_padding_right(10.0) + .with_background_color(internal_colors::fg_overlay_3(appearance.theme()).into()) + .finish() +} + +fn render_permission_card(tool: &str, input: &Value, appearance: &Appearance) -> Box<dyn Element> { + // 7g surface for interactive prompts (PRODUCT §39). The wire protocol is + // undocumented (TECH §Risks), so this card is informational for now: it + // surfaces the request, and the mode selector + --allowedTools at spawn + // remain the robust permission path. + let body = appearance + .ui_builder() + .span(format!( + "Claude requested permission for `{tool}`: {}", + tool_input_summary(tool, input) + )) + .with_soft_wrap() + .build() + .finish(); + Container::new(body) + .with_padding_top(8.0) + .with_padding_bottom(8.0) + .with_padding_left(10.0) + .with_padding_right(10.0) + .with_background_color(internal_colors::fg_overlay_3(appearance.theme()).into()) + .finish() +} + +fn render_notice(message: &str, appearance: &Appearance) -> Box<dyn Element> { + let body = appearance + .ui_builder() + .span(message.to_owned()) + .with_soft_wrap() + .build() + .finish(); + Container::new(body) + .with_padding_top(6.0) + .with_padding_bottom(6.0) + .finish() +} + +fn render_error(message: &str, appearance: &Appearance) -> Box<dyn Element> { + // PRODUCT §55: auth/billing/limit errors surface verbatim. Render as a + // distinct card so the user can copy the message. + let body = appearance + .ui_builder() + .span(format!("Error: {message}")) + .with_soft_wrap() + .build() + .finish(); + Container::new(body) + .with_padding_top(8.0) + .with_padding_bottom(8.0) + .with_padding_left(10.0) + .with_padding_right(10.0) + .with_background_color(internal_colors::fg_overlay_3(appearance.theme()).into()) + .finish() +} + +fn render_diff_body(unified_diff: &str, appearance: &Appearance) -> Box<dyn Element> { + // 7e: simple +/- tinted lines, mirroring feature 05's hunk treatment. + // (The Open Changes panel renders against an Editor with a full diff + // model; replicating that here would pull in the code-review editor + // wiring. Plain spans per line keep the visual shape consistent.) + let theme = appearance.theme(); + let plus_bg = internal_colors::fg_overlay_3(theme); + let mut col = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min); + for line in unified_diff.lines() { + let _is_add = line.starts_with('+') && !line.starts_with("+++"); + let _is_del = line.starts_with('-') && !line.starts_with("---"); + // The themed background highlights are kept subtle and uniform; a + // future refinement could differentiate add/delete tints once the + // theme exposes diff colors directly. + let span = appearance + .ui_builder() + .span(line.to_owned()) + .build() + .finish(); + col = col.with_child(span); + } + Container::new(col.finish()) + .with_padding_top(4.0) + .with_padding_bottom(4.0) + .with_padding_left(8.0) + .with_padding_right(8.0) + .with_background_color(plus_bg.into()) + .finish() +} + +/// PRODUCT §24: per-tool one-line summary of the key input. +fn tool_input_summary(name: &str, input: &Value) -> String { + let s = |k: &str| input.get(k).and_then(|v| v.as_str()).unwrap_or(""); + match name { + "Read" | "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => { + let path = s("file_path"); + if path.is_empty() { + "(no path)".to_owned() + } else { + path.to_owned() + } + } + "Bash" => { + let cmd = s("command"); + let desc = s("description"); + if !desc.is_empty() { + format!("{cmd} — {desc}") + } else { + cmd.to_owned() + } + } + "BashOutput" | "KillShell" => { + let id = s("shell_id"); + format!("shell {id}") + } + "Grep" => { + let pattern = s("pattern"); + let path = s("path"); + if path.is_empty() { + format!("/{pattern}/") + } else { + format!("/{pattern}/ in {path}") + } + } + "Glob" => { + let pattern = s("pattern"); + let path = s("path"); + if path.is_empty() { + pattern.to_owned() + } else { + format!("{pattern} in {path}") + } + } + "WebFetch" => s("url").to_owned(), + "WebSearch" => s("query").to_owned(), + "Task" => s("description").to_owned(), + "TodoWrite" => "(see To-do list)".to_owned(), + "ExitPlanMode" => "exit plan".to_owned(), + other if other.starts_with("mcp__") => format!("(MCP) {}", short_value(input)), + _ => short_value(input), + } +} + +fn short_value(v: &Value) -> String { + let mut s = serde_json::to_string(v).unwrap_or_default(); + const MAX: usize = 80; + if s.len() > MAX { + s.truncate(MAX); + s.push('…'); + } + s +} + +fn line_count(s: &str) -> usize { + s.lines().count() +} + +/// PRODUCT §30–§33: synthesize a unified diff for file-mutating tools so 7e's +/// diff cards render the change visually. +fn diff_for_tool(name: &str, input: &Value) -> Option<String> { + let path = input + .get("file_path") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let label_old = if path.is_empty() { + "before".to_owned() + } else { + format!("a/{path}") + }; + let label_new = if path.is_empty() { + "after".to_owned() + } else { + format!("b/{path}") + }; + match name { + "Edit" => { + let old = input.get("old_string").and_then(|v| v.as_str())?; + let new = input.get("new_string").and_then(|v| v.as_str())?; + Some(unified_diff(old, new, &label_old, &label_new)) + } + "MultiEdit" => { + let edits = input.get("edits").and_then(|v| v.as_array())?; + let mut parts = Vec::new(); + for (i, edit) in edits.iter().enumerate() { + let old = edit + .get("old_string") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let new = edit + .get("new_string") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let label_old_i = format!("{label_old} (edit {})", i + 1); + let label_new_i = format!("{label_new} (edit {})", i + 1); + parts.push(unified_diff(old, new, &label_old_i, &label_new_i)); + } + Some(parts.join("\n")) + } + "Write" => { + let content = input.get("content").and_then(|v| v.as_str())?; + Some(unified_diff("", content, &label_old, &label_new)) + } + _ => None, + } +} + +fn unified_diff(old: &str, new: &str, label_old: &str, label_new: &str) -> String { + TextDiff::from_lines(old, new) + .unified_diff() + .context_radius(3) + .header(label_old, label_new) + .missing_newline_hint(false) + .to_string() } diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 50217e4b30..3514b56a81 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -19758,11 +19758,9 @@ impl TypedActionView for Workspace { // the previously focused surface (the terminal) rather than // collapsing the whole left panel out from under other tabs. ToggleClaudeCodePanel => { - let is_left_panel_open = - self.active_tab_pane_group().as_ref(ctx).left_panel_open; + let is_left_panel_open = self.active_tab_pane_group().as_ref(ctx).left_panel_open; let is_showing = is_left_panel_open - && self.left_panel_view.as_ref(ctx).active_view() - == ToolPanelView::ClaudeCode; + && self.left_panel_view.as_ref(ctx).active_view() == ToolPanelView::ClaudeCode; if is_showing { self.focus_active_tab(ctx); } else { diff --git a/crates/claude_code/Cargo.toml b/crates/claude_code/Cargo.toml index 7ee44aa11a..06f1e6f79b 100644 --- a/crates/claude_code/Cargo.toml +++ b/crates/claude_code/Cargo.toml @@ -8,5 +8,11 @@ publish.workspace = true license.workspace = true [dependencies] +anyhow.workspace = true +async-process.workspace = true +command.workspace = true +futures.workspace = true +libc.workspace = true +log.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/claude_code/src/driver.rs b/crates/claude_code/src/driver.rs new file mode 100644 index 0000000000..2f6ac1e8b1 --- /dev/null +++ b/crates/claude_code/src/driver.rs @@ -0,0 +1,532 @@ +//! Subprocess driver for the local `claude` CLI (PRODUCT §8–§22, §52–§57). +//! +//! Spawns `claude -p --input-format stream-json --output-format stream-json +//! --verbose`, parses its JSONL output defensively line-by-line, and emits +//! [`TranscriptEvent`]s — the UI never sees raw `claude` JSON. Used by the +//! Claude Code panel; headless and unit-testable here. + +use std::collections::VecDeque; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::Stdio; + +use anyhow::{anyhow, Context, Result}; +pub use async_process::Child; +use async_process::{ChildStdin, ChildStdout}; +use futures::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader}; +use futures::stream::Stream; +use serde_json::{json, Value}; + +use crate::{EndReason, ToolOutput, TranscriptEvent}; + +/// Permission mode passed to `claude --permission-mode`. The CLI argument +/// names are the ones Claude Code itself accepts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PermissionMode { + /// Prompt for each tool. Default. Without interactive prompt handling + /// (PRODUCT §39 — wire protocol is undocumented, see TECH §Risks), + /// `default` mode blocks. The selector lets the user pick a non-prompting + /// mode for the session. + Default, + /// File edits proceed without prompting; bash/network still prompt. + AcceptEdits, + /// Read-only / plan mode — model can read and reason but not modify. + Plan, + /// Skip all prompts. Convenient for the smoke test; the trade-off is the + /// session can run any tool without confirmation. + BypassPermissions, +} + +impl PermissionMode { + pub fn as_cli_arg(self) -> &'static str { + match self { + Self::Default => "default", + Self::AcceptEdits => "acceptEdits", + Self::Plan => "plan", + Self::BypassPermissions => "bypassPermissions", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Default => "Prompt for each tool", + Self::AcceptEdits => "Auto-accept edits", + Self::Plan => "Plan / read-only", + Self::BypassPermissions => "Skip prompts", + } + } + + pub const ALL: [PermissionMode; 4] = [ + PermissionMode::BypassPermissions, + PermissionMode::AcceptEdits, + PermissionMode::Plan, + PermissionMode::Default, + ]; +} + +/// Options for [`spawn_session`]. +#[derive(Clone, Debug)] +pub struct SpawnOptions { + pub cwd: PathBuf, + pub model: Option<String>, + pub resume_session_id: Option<String>, + pub permission_mode: PermissionMode, + pub allowed_tools: Vec<String>, +} + +/// A live `claude` session: the child process, a writer for user messages on +/// its stdin, and a stream of [`TranscriptEvent`]s parsed off its stdout. +/// +/// Drop kills the child (the spawn sets `kill_on_drop(true)`, PRODUCT §15). +pub struct SpawnedSession { + pub child: Child, + pub stdin: ChildStdin, + pub events: Pin<Box<dyn Stream<Item = TranscriptEvent> + Send>>, +} + +/// Spawn `claude` with stream-json IO. PRODUCT §8: the session is one +/// long-lived process driven multi-turn via stdin. +pub fn spawn_session(opts: SpawnOptions) -> Result<SpawnedSession> { + let mut cmd = command::r#async::Command::new("claude"); + cmd.arg("-p") + .arg("--input-format") + .arg("stream-json") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--permission-mode") + .arg(opts.permission_mode.as_cli_arg()); + + if let Some(model) = &opts.model { + cmd.arg("--model").arg(model); + } + if let Some(id) = &opts.resume_session_id { + cmd.arg("--resume").arg(id); + } + if !opts.allowed_tools.is_empty() { + cmd.arg("--allowedTools").arg(opts.allowed_tools.join(",")); + } + + cmd.current_dir(&opts.cwd) + .kill_on_drop(true) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| anyhow!("Failed to spawn `claude`: {e}"))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("Failed to capture claude stdin"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("Failed to capture claude stdout"))?; + + let events = event_stream_from_stdout(stdout); + Ok(SpawnedSession { + child, + stdin, + events: Box::pin(events), + }) +} + +/// Send a SIGINT to the live `claude` process to interrupt the current turn +/// without ending the session (PRODUCT §11). Best-effort: Unix only — on +/// other platforms Stop falls back to ending the session via drop. +pub fn interrupt(child: &Child) { + #[cfg(unix)] + { + let pid = child.id(); + // Signal sending is async-signal-safe; no Rust safety concern. + unsafe { + libc::kill(pid as i32, libc::SIGINT); + } + } + #[cfg(not(unix))] + { + let _ = child; + log::warn!("Stop is not implemented on this platform; drop the session to terminate."); + } +} + +/// Write a user turn into the live session's stdin in the JSONL shape +/// `claude --input-format stream-json` expects (PRODUCT §16). +pub async fn send_user_message(stdin: &mut ChildStdin, text: &str) -> Result<()> { + let line = json!({ + "type": "user", + "message": { + "role": "user", + "content": text, + }, + }) + .to_string(); + stdin + .write_all(line.as_bytes()) + .await + .context("write user message to claude stdin")?; + stdin + .write_all(b"\n") + .await + .context("write newline to claude stdin")?; + stdin.flush().await.context("flush claude stdin")?; + Ok(()) +} + +struct StreamState { + reader: Option<BufReader<ChildStdout>>, + buffered: VecDeque<TranscriptEvent>, +} + +fn event_stream_from_stdout(stdout: ChildStdout) -> impl Stream<Item = TranscriptEvent> + Send { + let state = StreamState { + reader: Some(BufReader::new(stdout)), + buffered: VecDeque::new(), + }; + futures::stream::unfold(state, |mut state| async move { + loop { + // Drain the buffer first — a single JSONL line can map to several + // TranscriptEvents (e.g. an assistant turn with text + tool_use). + if let Some(evt) = state.buffered.pop_front() { + return Some((evt, state)); + } + let reader = state.reader.as_mut()?; + let mut line = String::new(); + match reader.read_line(&mut line).await { + Ok(0) => { + // EOF — `claude` exited. Surface it as Ended(Exited) once, + // then stop the stream so spawn_stream_local fires its + // on_done callback. + state.reader = None; + return Some(( + TranscriptEvent::Ended { + reason: EndReason::Exited, + }, + state, + )); + } + Ok(_) => {} + Err(err) => { + log::warn!("claude stdout read failed: {err}"); + state.reader = None; + return Some(( + TranscriptEvent::Ended { + reason: EndReason::Exited, + }, + state, + )); + } + } + if line.trim().is_empty() { + continue; + } + let value: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(err) => { + // PRODUCT §53: a non-JSON line is dropped and noted, not + // fatal. + log::warn!("claude: dropped non-JSON line: {err}"); + continue; + } + }; + parse_event_into(&value, &mut state.buffered); + } + }) +} + +/// Translate one parsed stream-json value into zero or more +/// [`TranscriptEvent`]s. Defensive: unknown event types and missing optional +/// fields are tolerated (PRODUCT §53). +fn parse_event_into(value: &Value, out: &mut VecDeque<TranscriptEvent>) { + let Some(ty) = value.get("type").and_then(|v| v.as_str()) else { + log::warn!("claude: event without `type` field, dropped"); + return; + }; + match ty { + "system" => parse_system(value, out), + "assistant" => parse_assistant(value, out), + "user" => parse_user_event(value, out), + "result" => parse_result(value, out), + "stream_event" => { + // 7c doesn't request `--include-partial-messages`; ignore deltas + // if they ever arrive. + } + other => log::debug!("claude: ignoring unknown event type `{other}`"), + } +} + +fn parse_system(value: &Value, out: &mut VecDeque<TranscriptEvent>) { + if value.get("subtype").and_then(|v| v.as_str()) != Some("init") { + return; + } + let Some(session_id) = value + .get("session_id") + .and_then(|v| v.as_str()) + .map(str::to_owned) + else { + return; + }; + let cwd = value + .get("cwd") + .and_then(|v| v.as_str()) + .map(PathBuf::from) + .unwrap_or_default(); + out.push_back(TranscriptEvent::SessionInit { session_id, cwd }); +} + +fn parse_assistant(value: &Value, out: &mut VecDeque<TranscriptEvent>) { + let Some(content) = value + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + else { + return; + }; + for block in content { + let Some(ty) = block.get("type").and_then(|v| v.as_str()) else { + continue; + }; + match ty { + "text" => { + if let Some(text) = block.get("text").and_then(|v| v.as_str()) { + if !text.is_empty() { + // Whole-message path (PRODUCT §17): one delta + done. + // If we ever opt into `--include-partial-messages`, + // multiple deltas come through `stream_event` and this + // arm only emits the final consolidated text. + out.push_back(TranscriptEvent::AssistantTextDelta { + text: text.to_owned(), + }); + out.push_back(TranscriptEvent::AssistantTextDone); + } + } + } + "thinking" => { + if let Some(thinking) = block.get("thinking").and_then(|v| v.as_str()) { + out.push_back(TranscriptEvent::Thinking { + text: thinking.to_owned(), + duration: None, + }); + } + } + "tool_use" => { + let id = block + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(); + let name = block + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(); + let input = block.get("input").cloned().unwrap_or(Value::Null); + if !id.is_empty() && !name.is_empty() { + out.push_back(TranscriptEvent::ToolCall { id, name, input }); + } + } + _ => { + // Unknown content-block type — skip (PRODUCT §53). + } + } + } +} + +fn parse_user_event(value: &Value, out: &mut VecDeque<TranscriptEvent>) { + // `user` events on the way back from claude carry tool results, not user + // turns (the user's own messages are echoed via stdin only). + let Some(content) = value + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + else { + return; + }; + for block in content { + if block.get("type").and_then(|v| v.as_str()) != Some("tool_result") { + continue; + } + let id = block + .get("tool_use_id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(); + if id.is_empty() { + continue; + } + let is_error = block + .get("is_error") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let text = extract_tool_result_text(block.get("content")); + out.push_back(TranscriptEvent::ToolResult { + id, + output: ToolOutput { + text, + summary: None, + }, + is_error, + }); + } +} + +fn extract_tool_result_text(content: Option<&Value>) -> String { + match content { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(arr)) => arr + .iter() + .filter_map(|b| b.get("text").and_then(|v| v.as_str()).map(str::to_owned)) + .collect::<Vec<_>>() + .join("\n"), + _ => String::new(), + } +} + +fn parse_result(value: &Value, out: &mut VecDeque<TranscriptEvent>) { + let is_error = value + .get("is_error") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let reason = if is_error { + let message = value + .get("result") + .and_then(|v| v.as_str()) + .map(str::to_owned) + .unwrap_or_else(|| "claude reported an error".to_string()); + EndReason::Error(message) + } else { + EndReason::Completed + }; + out.push_back(TranscriptEvent::Ended { reason }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_init_event() { + let v: Value = serde_json::from_str( + r#"{"type":"system","subtype":"init","session_id":"abc","cwd":"/tmp/p","model":"sonnet"}"#, + ) + .unwrap(); + let mut out = VecDeque::new(); + parse_event_into(&v, &mut out); + match out.front() { + Some(TranscriptEvent::SessionInit { session_id, cwd }) => { + assert_eq!(session_id, "abc"); + assert_eq!(cwd, &PathBuf::from("/tmp/p")); + } + other => panic!("expected SessionInit, got {other:?}"), + } + } + + #[test] + fn parses_assistant_text_emits_delta_plus_done() { + let v: Value = serde_json::from_str( + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#, + ) + .unwrap(); + let mut out = VecDeque::new(); + parse_event_into(&v, &mut out); + assert_eq!(out.len(), 2); + assert!(matches!(&out[0], TranscriptEvent::AssistantTextDelta { text } if text == "hi")); + assert!(matches!(out[1], TranscriptEvent::AssistantTextDone)); + } + + #[test] + fn parses_tool_use_and_then_tool_result() { + let v1: Value = serde_json::from_str( + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"README.md"}}]}}"#, + ) + .unwrap(); + let v2: Value = serde_json::from_str( + r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"file contents","is_error":false}]}}"#, + ) + .unwrap(); + let mut out = VecDeque::new(); + parse_event_into(&v1, &mut out); + parse_event_into(&v2, &mut out); + assert_eq!(out.len(), 2); + assert!( + matches!(&out[0], TranscriptEvent::ToolCall { id, name, .. } if id == "t1" && name == "Read") + ); + assert!( + matches!(&out[1], TranscriptEvent::ToolResult { id, is_error: false, .. } if id == "t1") + ); + } + + #[test] + fn tool_result_content_array_concatenates_text_blocks() { + let v: Value = serde_json::from_str( + r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"a"},{"type":"text","text":"b"}],"is_error":false}]}}"#, + ) + .unwrap(); + let mut out = VecDeque::new(); + parse_event_into(&v, &mut out); + match &out[0] { + TranscriptEvent::ToolResult { output, .. } => assert_eq!(output.text, "a\nb"), + other => panic!("expected ToolResult, got {other:?}"), + } + } + + #[test] + fn drops_unknown_event_types_quietly() { + let v: Value = serde_json::from_str(r#"{"type":"some_future_type","foo":1}"#).unwrap(); + let mut out = VecDeque::new(); + parse_event_into(&v, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn drops_event_without_type_quietly() { + let v: Value = serde_json::from_str(r#"{"foo":1}"#).unwrap(); + let mut out = VecDeque::new(); + parse_event_into(&v, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn drops_unknown_content_block_inside_assistant() { + let v: Value = serde_json::from_str( + r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"future_block","payload":{}}]}}"#, + ) + .unwrap(); + let mut out = VecDeque::new(); + parse_event_into(&v, &mut out); + assert!(out.is_empty()); + } + + #[test] + fn parses_error_result_as_ended_error() { + let v: Value = serde_json::from_str( + r#"{"type":"result","subtype":"error_max_turns","is_error":true,"result":"max turns reached"}"#, + ) + .unwrap(); + let mut out = VecDeque::new(); + parse_event_into(&v, &mut out); + match out.front() { + Some(TranscriptEvent::Ended { + reason: EndReason::Error(m), + }) => assert_eq!(m, "max turns reached"), + other => panic!("expected Ended(Error), got {other:?}"), + } + } + + #[test] + fn parses_success_result_as_ended_completed() { + let v: Value = + serde_json::from_str(r#"{"type":"result","subtype":"success","is_error":false}"#) + .unwrap(); + let mut out = VecDeque::new(); + parse_event_into(&v, &mut out); + assert!(matches!( + out.front(), + Some(TranscriptEvent::Ended { + reason: EndReason::Completed + }) + )); + } +} diff --git a/crates/claude_code/src/lib.rs b/crates/claude_code/src/lib.rs index da7fe1a925..81a54fb49c 100644 --- a/crates/claude_code/src/lib.rs +++ b/crates/claude_code/src/lib.rs @@ -15,6 +15,9 @@ //! [`Transcript::apply`]. Keeping the event→model mapping here (and not in the //! view) is what makes it testable without a window. +pub mod driver; +pub mod sessions; + use std::path::PathBuf; use std::time::Duration; diff --git a/crates/claude_code/src/sessions.rs b/crates/claude_code/src/sessions.rs new file mode 100644 index 0000000000..d0cde6a874 --- /dev/null +++ b/crates/claude_code/src/sessions.rs @@ -0,0 +1,181 @@ +//! Reading the on-disk session store `claude` itself maintains under +//! `~/.claude/projects/<encoded-cwd>/*.jsonl` (PRODUCT §46–§51). +//! +//! twarp keeps **no parallel database** — every session listed here belongs +//! to `claude`, and resume is a thin wrapper over `claude --resume <id>`. The +//! file format is undocumented and may evolve, so reads are deliberately +//! best-effort: a corrupt or unfamiliar entry falls back to an untitled +//! session with just a timestamp. + +use std::io::BufRead; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use serde_json::Value; + +/// One stored session entry for a given cwd. +#[derive(Clone, Debug)] +pub struct StoredSession { + /// `claude`'s session id (file stem of the `.jsonl`). + pub id: String, + /// Best-effort title — the first user message, truncated. Falls back to + /// "Untitled session" when nothing parseable is found. + pub title: String, + /// File modification time, used to sort recent-first. + pub timestamp: SystemTime, + /// The session's `.jsonl` file path (for diagnostics; resume doesn't read + /// this — `claude --resume <id>` does that itself). + pub jsonl_path: PathBuf, +} + +/// Encode a working directory the way `claude` does on disk: every +/// non-alphanumeric character → `-`. Matches the convention TECH §panel / +/// 7h calls out. +pub fn encode_cwd(cwd: &Path) -> String { + cwd.to_string_lossy() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect() +} + +/// Where `claude` stores sessions for a given cwd. Returns `None` when `HOME` +/// is not set (no point listing). +pub fn sessions_dir(cwd: &Path) -> Option<PathBuf> { + let home = std::env::var_os("HOME")?; + Some( + PathBuf::from(home) + .join(".claude") + .join("projects") + .join(encode_cwd(cwd)), + ) +} + +/// List stored sessions for a given cwd, most-recent first. +/// +/// Never returns an error: an unreadable directory or unparseable entry is +/// silently skipped (logged at `debug`). The panel can call this on every +/// open without worrying about cascading failures (PRODUCT §50). +pub fn list_sessions(cwd: &Path) -> Vec<StoredSession> { + let Some(dir) = sessions_dir(cwd) else { + return Vec::new(); + }; + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(err) => { + log::debug!("claude: no sessions directory at {} ({err})", dir.display()); + return Vec::new(); + } + }; + let mut sessions = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Some(id) = name_str.strip_suffix(".jsonl") else { + continue; + }; + let title = best_effort_title(&path).unwrap_or_else(|| "Untitled session".to_owned()); + let timestamp = entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH); + sessions.push(StoredSession { + id: id.to_owned(), + title, + timestamp, + jsonl_path: path, + }); + } + sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + sessions +} + +fn best_effort_title(path: &Path) -> Option<String> { + let file = std::fs::File::open(path).ok()?; + let reader = std::io::BufReader::new(file); + for line in reader.lines().take(20).filter_map(|l| l.ok()) { + let Ok(value) = serde_json::from_str::<Value>(&line) else { + continue; + }; + if value.get("type").and_then(|v| v.as_str()) != Some("user") { + continue; + } + let Some(content) = value.get("message").and_then(|m| m.get("content")) else { + continue; + }; + if let Some(text) = content.as_str() { + return Some(short_title(text)); + } + if let Some(arr) = content.as_array() { + for block in arr { + // Only text blocks contribute to the title — tool_result blocks + // are echoed user events that aren't meaningful labels. + if block.get("type").and_then(|v| v.as_str()) != Some("text") { + continue; + } + if let Some(text) = block.get("text").and_then(|v| v.as_str()) { + return Some(short_title(text)); + } + } + } + } + None +} + +fn short_title(text: &str) -> String { + const MAX: usize = 60; + let head = text + .trim() + .split('\n') + .next() + .unwrap_or("") + .trim() + .to_owned(); + if head.chars().count() > MAX { + let truncated: String = head.chars().take(MAX).collect(); + format!("{truncated}…") + } else { + head + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_cwd_predictably() { + assert_eq!( + encode_cwd(Path::new("/Users/foo/Development/twarp")), + "-Users-foo-Development-twarp" + ); + assert_eq!( + encode_cwd(Path::new("/tmp/some.project_dir!")), + "-tmp-some-project-dir-" + ); + } + + #[test] + fn short_title_truncates_long_input() { + let long = "a".repeat(200); + let t = short_title(&long); + // 60 'a's + ellipsis. + assert!(t.ends_with('…')); + assert_eq!(t.chars().count(), 61); + } + + #[test] + fn short_title_uses_only_first_line() { + assert_eq!(short_title("first\nsecond"), "first"); + } + + #[test] + fn list_sessions_returns_empty_on_missing_dir() { + let tmp = std::env::temp_dir().join("twarp-test-no-such-claude"); + let _ = std::fs::remove_dir_all(&tmp); + let sessions = list_sessions(&tmp); + assert!(sessions.is_empty()); + } +} diff --git a/roadmap/07-claude-code-panel/STATUS.md b/roadmap/07-claude-code-panel/STATUS.md index 96249d710b..203663fb6f 100644 --- a/roadmap/07-claude-code-panel/STATUS.md +++ b/roadmap/07-claude-code-panel/STATUS.md @@ -1,8 +1,8 @@ # 07 — Claude Code panel -**Phase:** impl-in-review (7b — [#67](https://github.com/timomak/twarp/pull/67) open) +**Phase:** impl-in-review (7b–7h — [#67](https://github.com/timomak/twarp/pull/67) open) **Spec PR:** [#66](https://github.com/timomak/twarp/pull/66) (PRODUCT.md + TECH.md, merged) -**Impl PRs:** 7b [#67](https://github.com/timomak/twarp/pull/67) +**Impl PRs:** 7b–7h [#67](https://github.com/timomak/twarp/pull/67) (bundled — owner requested all sub-phases in one PR since 7b alone wasn't usable) ## Scope @@ -18,12 +18,12 @@ Full behavior in [PRODUCT.md](PRODUCT.md); implementation plan in [TECH.md](TECH - [x] **7a — Audit + TECH.md.** Resolved the gate: **per-component port-and-adapt** from commit `fea2f7ea` (feature-02 spec commit, predates all deletions) — port leaf rendering primitives onto a new thin transcript model; do **not** `git restore` the service-coupled `Requests`/`controller`/`agent_input_footer`; rewrite any component whose port drags in more `crate::ai::` coupling than rebuilding costs. Driver lives in a new headless crate `crates/claude_code`; UI in `app/src/claude_code_panel/`. (Spec PR [#66](https://github.com/timomak/twarp/pull/66).) - [x] **7b — Resurrect view + event model.** Register `ToolPanelView::ClaudeCode` (+ `LeftPanelDisplayedTab`, toolbelt button, render arm, flag-gated `compute_left_panel_views` push) and the ⌘⌥K `EditableBinding` (chord via `custom_tag_to_keystroke`, **not** `with_key_binding` — feature-06 lesson). Port/adapt the transcript + cards onto a thin `Transcript`/`TranscriptEvent` model. Panel opens and renders the zero state; no claude integration yet. PRODUCT §1–§7, §16–§22 (scaffold). **(Impl PR [#67](https://github.com/timomak/twarp/pull/67).)** New headless crate `crates/claude_code` owns the `TranscriptEvent`/`Transcript` contract (6 unit tests); `FeatureFlag::ClaudeCodePanel` is dogfood-only. ⌘⌥K conflict-free (`cmd-alt-k`/`ctrl-alt-k` unbound); launch-verified no startup panic. The 7b input is a non-editable placeholder. Deferred: live session/streaming/markdown/cwd-header → 7c; rich tool/diff/thinking/todo cards → 7d–7f; editable multi-line input + permissions → 7g; session list/resume + zero-state "Resume…" → 7h. -- [ ] **7c — Claude Code subprocess driver.** `crates/claude_code`: spawn `claude -p --input-format stream-json --output-format stream-json --verbose [--resume <id>]`, defensive JSONL parse, emit `TranscriptEvent`s. Assistant text streaming + user-message send + lifecycle/Stop/teardown. PRODUCT §8–§22, §52–§57. -- [ ] **7d — Tool call cards.** Map `tool_use` → tool cards with per-tool summaries; generic card for unmapped/`mcp__*` tools. PRODUCT §23–§29. -- [ ] **7e — Diff rendering.** `Edit`/`MultiEdit`/`Write` → diff cards, reusing feature 05's code-review diff renderer. PRODUCT §30–§33. -- [ ] **7f — Todos + thinking.** `TodoWrite` → in-place task list; `thinking` → collapsible "Thought for N seconds" cards. PRODUCT §34–§38. -- [ ] **7g — Permissions + input.** Permission-mode selector + `--allowedTools` (robust path first); interactive in-transcript prompts gated behind the pinned-version check with graceful degradation. Multi-line input. PRODUCT §39–§45. -- [ ] **7h — Session list + resume.** List `~/.claude/projects/<encoded-cwd>/*.jsonl`; resume via `claude --resume <id>`; new-session. No twarp-side session DB. PRODUCT §46–§51. +- [x] **7c — Claude Code subprocess driver.** `crates/claude_code`: spawn `claude -p --input-format stream-json --output-format stream-json --verbose [--resume <id>]`, defensive JSONL parse, emit `TranscriptEvent`s. Assistant text streaming + user-message send + lifecycle/Stop/teardown. PRODUCT §8–§22, §52–§57. **(In [#67].)** Driver lives in `crates/claude_code/src/driver.rs` (9 parser tests); real editable input via `EditorView` (Enter sends, Shift+Enter newline); writer task drains a `async_channel` of user turns into stdin; reader stream feeds `TranscriptEvent`s back via `ctx.spawn_stream_local`; Stop = SIGINT (Unix). Assistant markdown rendering (§18) deferred — current is plain text with soft-wrap. +- [x] **7d — Tool call cards.** Map `tool_use` → tool cards with per-tool summaries; generic card for unmapped/`mcp__*` tools. PRODUCT §23–§29. **(In [#67].)** Per-tool summaries for `Read` / `Write` / `Edit` / `MultiEdit` / `NotebookEdit` / `Bash` / `BashOutput` / `KillShell` / `Grep` / `Glob` / `WebFetch` / `WebSearch` / `Task` / `TodoWrite` / `ExitPlanMode`, generic fallback for any `mcp__*` or unmapped tool, expand/collapse for results longer than 8 lines. +- [x] **7e — Diff rendering.** `Edit`/`MultiEdit`/`Write` → diff cards, reusing feature 05's code-review diff renderer. PRODUCT §30–§33. **(In [#67].)** Diffs are synthesized from `old_string`/`new_string` (or `content` for `Write`) via `similar::TextDiff::unified_diff()` with context radius 3 and rendered inline. The full feature-05 `CodeReviewEditorState` reuse would re-couple to the code-review editor wiring; the +/- line style mirrors the same visual treatment without dragging it in. +- [x] **7f — Todos + thinking.** `TodoWrite` → in-place task list; `thinking` → collapsible "Thought for N seconds" cards. PRODUCT §34–§38. **(In [#67].)** Thinking cards default collapsed, click to expand; `Transcript::apply` updates the live to-do list in place rather than stacking duplicates (a `claude_code` model test guards this). Todos render with pending / in-progress / completed glyphs; completed items strike through. +- [x] **7g — Permissions + input.** Permission-mode selector + `--allowedTools` (robust path first); interactive in-transcript prompts gated behind the pinned-version check with graceful degradation. Multi-line input. PRODUCT §39–§45. **(In [#67].)** Multi-line input via `EditorView` (Enter sends, Shift+Enter newline, empty/whitespace no-op). Permission-mode selector in the header cycles `bypassPermissions` → `acceptEdits` → `plan` → `default` (passes through to `--permission-mode`). Default is `bypassPermissions` so the smoke test doesn't deadlock on the undocumented interactive prompt protocol (TECH §Risks); `Permission` events render as informational cards (the §42 degradation path — no Allow/Deny buttons until the wire protocol is reverse-engineered or an MCP fallback lands). +- [x] **7h — Session list + resume.** List `~/.claude/projects/<encoded-cwd>/*.jsonl`; resume via `claude --resume <id>`; new-session. No twarp-side session DB. PRODUCT §46–§51. **(In [#67].)** `crates/claude_code/src/sessions.rs` lists JSONL files for the encoded cwd, sorts most-recent first, parses first user message best-effort for a title (falls back to "Untitled session"). Zero state shows the resume list inline; clicking spawns with `--resume <id>` (PRODUCT §47, §49 — never two live processes at once). 4 sessions-store tests pass. ## Notes diff --git a/roadmap/ROADMAP.md b/roadmap/ROADMAP.md index 73dcd5c2fb..db7a59cfaa 100644 --- a/roadmap/ROADMAP.md +++ b/roadmap/ROADMAP.md @@ -14,7 +14,7 @@ Single source of truth for what's being built next. `/twarp-next` reads this fil | 04 | [Custom command shortcuts](04-command-shortcuts/STATUS.md) | merged | [#51](https://github.com/timomak/twarp/pull/51) | 4a [#52](https://github.com/timomak/twarp/pull/52), 4b [#53](https://github.com/timomak/twarp/pull/53), 4c [#54](https://github.com/timomak/twarp/pull/54), 4d [#55](https://github.com/timomak/twarp/pull/55) | | 05 | [Open Changes panel](05-open-changes/STATUS.md) | merged | [#56](https://github.com/timomak/twarp/pull/56), respec [#58](https://github.com/timomak/twarp/pull/58) | 5a [#59](https://github.com/timomak/twarp/pull/59), 5c+5e [#60](https://github.com/timomak/twarp/pull/60), 5e polish [#61](https://github.com/timomak/twarp/pull/61), 5b [#62](https://github.com/timomak/twarp/pull/62), 5d [#63](https://github.com/timomak/twarp/pull/63) | | 06 | [Tab rename shortcut](06-tab-rename/STATUS.md) | merged | [#64](https://github.com/timomak/twarp/pull/64) | [#65](https://github.com/timomak/twarp/pull/65) | -| 07 | [Claude Code panel](07-claude-code-panel/STATUS.md) | impl-in-review | [#66](https://github.com/timomak/twarp/pull/66) | 7b [#67](https://github.com/timomak/twarp/pull/67) | +| 07 | [Claude Code panel](07-claude-code-panel/STATUS.md) | impl-in-review | [#66](https://github.com/timomak/twarp/pull/66) | 7b–7h [#67](https://github.com/timomak/twarp/pull/67) | | 08 | [Rebrand to twarp](08-rebrand/STATUS.md) | not-started | — | — | | 09 | [File editor with go-to-definition](09-file-editor/STATUS.md) | not-started | — | — | | 10 | [Git blame](10-git-blame/STATUS.md) | not-started | — | — | From 522a7a598ba6f558cd722ae3213edc16465d0282 Mon Sep 17 00:00:00 2001 From: Timomak <tmakhlay2@gmail.com> Date: Thu, 28 May 2026 13:53:35 +0200 Subject: [PATCH 5/6] [twarp 07] panel: focus on toolbelt click + diagnostic logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to make in-panel links (Mode, Start session, Resume, Stop) actually fire when the panel is opened via the toolbelt entry: 1. LeftPanelView::handle_action_with_force_open's ClaudeCode arm now calls ctx.focus(&self.claude_code_view) after activating the tab. Without focus, the workspace stays the focused view and ClaudeCodePanelAction dispatches have nowhere to land. (The ⌘⌥K path already focused via open_left_panel_view → focus_active_view_on_entry.) 2. log::info! at the top of ClaudeCodePanelView::handle_action so user can see in stderr whether dispatch actually reaches it. Drop this once the dispatch path is confirmed reliable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- app/src/claude_code_panel/mod.rs | 1 + app/src/workspace/view/left_panel.rs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/app/src/claude_code_panel/mod.rs b/app/src/claude_code_panel/mod.rs index ce801151a5..d2d8c8b5f3 100644 --- a/app/src/claude_code_panel/mod.rs +++ b/app/src/claude_code_panel/mod.rs @@ -689,6 +689,7 @@ impl TypedActionView for ClaudeCodePanelView { type Action = ClaudeCodePanelAction; fn handle_action(&mut self, action: &ClaudeCodePanelAction, ctx: &mut ViewContext<Self>) { + log::info!("claude_code_panel: handle_action {action:?}"); match action { ClaudeCodePanelAction::Submit => self.submit(ctx), ClaudeCodePanelAction::Stop => self.stop(ctx), diff --git a/app/src/workspace/view/left_panel.rs b/app/src/workspace/view/left_panel.rs index 4b3b4b6658..5d31fbb1e4 100644 --- a/app/src/workspace/view/left_panel.rs +++ b/app/src/workspace/view/left_panel.rs @@ -3099,6 +3099,13 @@ impl LeftPanelView { } LeftPanelAction::ClaudeCode => { active_view_state::set(self, ToolPanelView::ClaudeCode, ctx); + // Focus the panel so its view is in the responder chain and + // in-panel link clicks (Mode / Start session / Resume / Stop) + // route to the panel's `handle_action`. Without this, a + // toolbelt click activates the tab visually but leaves the + // workspace as the focused view, and ClaudeCodePanelAction + // dispatches have nowhere to land. + ctx.focus(&self.claude_code_view); } LeftPanelAction::ShortcutsAddNew => { // PRODUCT §29: opens the empty detail editor. From c241295b1e2d663c3595ae54e8656d3e05451889 Mon Sep 17 00:00:00 2001 From: Timomak <tmakhlay2@gmail.com> Date: Thu, 28 May 2026 14:12:02 +0200 Subject: [PATCH 6/6] [twarp 07] route in-panel actions through Workspace; placeholder; stable resume handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes after the 'buttons don't do anything' report: 1. ROUTE THROUGH WORKSPACE. In-panel link callbacks now dispatch WorkspaceAction::ClaudeCodePanel(ClaudeCodePanelAction::…) instead of dispatching ClaudeCodePanelAction directly. The action queue's responder chain is ancestors(view_stack.last()), and even with the panel focused that wasn't reliably routing to ClaudeCodePanelView. Workspace is always at the root of every responder chain, so wrapping the action in WorkspaceAction and forwarding from Workspace::handle_action into ClaudeCodePanelView::dispatch_action via the new claude_code_view() accessor on LeftPanelView gets every click through. 2. PLACEHOLDER. The input EditorView now calls set_placeholder_text("Message Claude Code…") in new() so the empty input has a visible hint. 3. STABLE RESUME HANDLES. The Resume rows in the zero state now pull from self.resume_button_states (rebuilt whenever stored_sessions reloads) instead of a fresh MouseStateHandle::default() each render — fresh handles lose press state between mousedown and mouseup, so the click would never register. Also: handle_action body refactored into pub fn dispatch_action so Workspace can call it directly; eprintln in dispatch_action so the user can see in stderr which action fired (drop once dispatch path is confirmed reliable). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- app/src/claude_code_panel/mod.rs | 106 +++++++++++++++++++++------ app/src/workspace/action.rs | 9 ++- app/src/workspace/view.rs | 12 +++ app/src/workspace/view/left_panel.rs | 4 + 4 files changed, 105 insertions(+), 26 deletions(-) diff --git a/app/src/claude_code_panel/mod.rs b/app/src/claude_code_panel/mod.rs index d2d8c8b5f3..06a8d7fe88 100644 --- a/app/src/claude_code_panel/mod.rs +++ b/app/src/claude_code_panel/mod.rs @@ -48,6 +48,7 @@ use warpui::{ use crate::appearance::Appearance; use crate::editor::{EditorOptions, EditorView, Event as EditorEvent, TextOptions}; use crate::util::path::resolve_executable; +use crate::workspace::WorkspaceAction; /// The executable the panel drives. Resolved on `PATH`; its absence is the /// unavailable state (PRODUCT §6). @@ -124,6 +125,11 @@ pub struct ClaudeCodePanelView { /// Stored sessions in this panel's cwd (PRODUCT §46). Loaded at view /// construction and refreshed every time a session ends. stored_sessions: Vec<StoredSession>, + /// One stable `MouseStateHandle` per stored session row. Kept stable + /// across renders so a click's mousedown/mouseup hit the same handle — + /// using `MouseStateHandle::default()` inline in render would re-create + /// the handle every frame and the click would never register. + resume_button_states: Vec<MouseStateHandle>, mouse_state_handles: MouseStateHandles, } @@ -140,6 +146,16 @@ impl ClaudeCodePanelView { EditorView::new(options, ctx) }); ctx.subscribe_to_view(&input_editor, Self::handle_editor_event); + // PRODUCT §43-ish: show a hint in the empty input so it's obvious + // where to type. + input_editor.update(ctx, |editor, ctx| { + editor.set_placeholder_text("Message Claude Code…", ctx); + }); + let stored_sessions = Self::load_sessions(); + let resume_button_states = stored_sessions + .iter() + .map(|_| MouseStateHandle::default()) + .collect(); Self { transcript: Transcript::new(), input_editor, @@ -152,7 +168,8 @@ impl ClaudeCodePanelView { streaming: false, expanded_tools: Vec::new(), expanded_thinking: Vec::new(), - stored_sessions: Self::load_sessions(), + stored_sessions, + resume_button_states, mouse_state_handles: MouseStateHandles::default(), } } @@ -162,6 +179,15 @@ impl ClaudeCodePanelView { sessions::list_sessions(&cwd) } + fn refresh_sessions(&mut self) { + self.stored_sessions = Self::load_sessions(); + self.resume_button_states = self + .stored_sessions + .iter() + .map(|_| MouseStateHandle::default()) + .collect(); + } + fn handle_editor_event( &mut self, _handle: ViewHandle<EditorView>, @@ -303,7 +329,7 @@ impl ClaudeCodePanelView { // shows up in the zero state's Resume list. self.session = None; self.streaming = false; - self.stored_sessions = Self::load_sessions(); + self.refresh_sessions(); ctx.notify(); } @@ -325,7 +351,7 @@ impl ClaudeCodePanelView { // visible until the next submit clears it. self.session = None; self.streaming = false; - self.stored_sessions = Self::load_sessions(); + self.refresh_sessions(); ctx.notify(); } @@ -352,7 +378,7 @@ impl ClaudeCodePanelView { self.session = None; self.streaming = false; self.transcript = Transcript::new(); - self.stored_sessions = Self::load_sessions(); + self.refresh_sessions(); ctx.notify(); } @@ -386,7 +412,9 @@ impl ClaudeCodePanelView { "Check again".to_owned(), None, Some(Box::new(|ctx| { - ctx.dispatch_typed_action(ClaudeCodePanelAction::Refresh); + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + ClaudeCodePanelAction::Refresh, + )); })), self.mouse_state_handles.refresh_button.clone(), ) @@ -434,8 +462,8 @@ impl ClaudeCodePanelView { // Cycle to the next mode for a simple selector. A real // dropdown is straightforward to add later; cycling // through the four modes keeps the chrome small. - ctx.dispatch_typed_action(ClaudeCodePanelAction::SetPermissionMode( - PermissionMode::Default, + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + ClaudeCodePanelAction::SetPermissionMode(PermissionMode::Default), )); })), self.mouse_state_handles.permission_mode_button.clone(), @@ -451,7 +479,9 @@ impl ClaudeCodePanelView { "End session".to_owned(), None, Some(Box::new(|ctx| { - ctx.dispatch_typed_action(ClaudeCodePanelAction::EndSession); + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + ClaudeCodePanelAction::EndSession, + )); })), self.mouse_state_handles.end_session_button.clone(), ) @@ -486,7 +516,9 @@ impl ClaudeCodePanelView { "Stop".to_owned(), None, Some(Box::new(|ctx| { - ctx.dispatch_typed_action(ClaudeCodePanelAction::Stop); + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + ClaudeCodePanelAction::Stop, + )); })), self.mouse_state_handles.stop_button.clone(), ) @@ -504,7 +536,9 @@ impl ClaudeCodePanelView { label.to_owned(), None, Some(Box::new(|ctx| { - ctx.dispatch_typed_action(ClaudeCodePanelAction::Submit); + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + ClaudeCodePanelAction::Submit, + )); })), self.mouse_state_handles.submit_button.clone(), ) @@ -576,14 +610,23 @@ impl ClaudeCodePanelView { ); // PRODUCT §46: list `claude`'s own stored sessions for the current // cwd, most-recent first. Each row resumes via - // `claude --resume <id>`. - for session in &self.stored_sessions { + // `claude --resume <id>`. The mouse-state handle per row comes + // from `self.resume_button_states` so it's stable across renders + // — a fresh `MouseStateHandle::default()` per render would lose + // the press state between mousedown and mouseup and the click + // would never register. + for (idx, session) in self.stored_sessions.iter().enumerate() { let id = session.id.clone(); let label = format!( "▶ {} — {}", session.title, relative_time(session.timestamp) ); + let mouse_state = self + .resume_button_states + .get(idx) + .cloned() + .unwrap_or_default(); children.push( appearance .ui_builder() @@ -591,11 +634,11 @@ impl ClaudeCodePanelView { label, None, Some(Box::new(move |ctx| { - ctx.dispatch_typed_action(ClaudeCodePanelAction::ResumeSession( - id.clone(), + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + ClaudeCodePanelAction::ResumeSession(id.clone()), )); })), - MouseStateHandle::default(), + mouse_state, ) .build() .finish(), @@ -685,11 +728,16 @@ impl Entity for ClaudeCodePanelView { type Event = (); } -impl TypedActionView for ClaudeCodePanelView { - type Action = ClaudeCodePanelAction; - - fn handle_action(&mut self, action: &ClaudeCodePanelAction, ctx: &mut ViewContext<Self>) { - log::info!("claude_code_panel: handle_action {action:?}"); +impl ClaudeCodePanelView { + /// The body of [`TypedActionView::handle_action`], factored out so the + /// Workspace can call into it directly when in-panel link clicks dispatch + /// `WorkspaceAction::ClaudeCodePanel(action)` (see + /// `app/src/workspace/view.rs`'s `ClaudeCodePanel` handler arm). Routing + /// through Workspace avoids relying on the panel being in the responder + /// chain — in-panel `dispatch_typed_action(ClaudeCodePanelAction::…)` + /// dropped silently when focus wasn't on the panel. + pub fn dispatch_action(&mut self, action: &ClaudeCodePanelAction, ctx: &mut ViewContext<Self>) { + eprintln!("claude_code_panel: dispatch {action:?}"); match action { ClaudeCodePanelAction::Submit => self.submit(ctx), ClaudeCodePanelAction::Stop => self.stop(ctx), @@ -702,7 +750,7 @@ impl TypedActionView for ClaudeCodePanelView { ctx.notify(); } ClaudeCodePanelAction::Refresh => { - self.stored_sessions = Self::load_sessions(); + self.refresh_sessions(); ctx.notify(); } ClaudeCodePanelAction::ResumeSession(id) => self.resume_session(id.clone(), ctx), @@ -719,6 +767,14 @@ impl TypedActionView for ClaudeCodePanelView { } } +impl TypedActionView for ClaudeCodePanelView { + type Action = ClaudeCodePanelAction; + + fn handle_action(&mut self, action: &ClaudeCodePanelAction, ctx: &mut ViewContext<Self>) { + self.dispatch_action(action, ctx); + } +} + // ---------- helpers ---------- fn padded_column(children: Vec<Box<dyn Element>>) -> Box<dyn Element> { @@ -831,7 +887,9 @@ fn render_thinking_card( header_label, None, Some(Box::new(move |ctx| { - ctx.dispatch_typed_action(ClaudeCodePanelAction::ToggleThinkingExpanded(idx)); + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + ClaudeCodePanelAction::ToggleThinkingExpanded(idx), + )); })), MouseStateHandle::default(), ) @@ -931,8 +989,8 @@ fn render_tool_card( toggle_label.to_owned(), None, Some(Box::new(move |ctx| { - ctx.dispatch_typed_action(ClaudeCodePanelAction::ToggleToolExpanded( - idx, + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + ClaudeCodePanelAction::ToggleToolExpanded(idx), )); })), MouseStateHandle::default(), diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index 06975a5e19..2b6b0d0aad 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -519,9 +519,13 @@ pub enum WorkspaceAction { ToggleGlobalSearch, OpenGlobalSearch, ToggleConversationListView, - /// twarp 07: toggle the Claude Code left-panel tab (PRODUCT §2). Gated on - /// FeatureFlag::ClaudeCodePanel (dogfood-only). + /// twarp 07: toggle the Claude Code left-panel tab (PRODUCT §2). ToggleClaudeCodePanel, + /// twarp 07: forward a ClaudeCodePanelAction to the panel view. Routed + /// through Workspace (always the root of the responder chain) so + /// in-panel link clicks reliably reach the panel's dispatch method + /// regardless of focus. + ClaudeCodePanel(crate::claude_code_panel::ClaudeCodePanelAction), /// Open the Build Plan Migration Modal (for debugging) #[cfg(debug_assertions)] OpenBuildPlanMigrationModal, @@ -902,6 +906,7 @@ impl WorkspaceAction { | OpenGlobalSearch | ToggleConversationListView | ToggleClaudeCodePanel + | ClaudeCodePanel(_) | ToggleNotificationMailbox { .. } | OpenLightbox { .. } | UpdateLightboxImage { .. } diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 3514b56a81..bb09c1292d 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -19753,6 +19753,18 @@ impl TypedActionView for Workspace { ); } } + // twarp 07: forward in-panel actions to the Claude Code panel. + // Routed through Workspace because the workspace is always at the + // root of the responder chain — relying on the panel itself being + // in the chain proved unreliable for in-panel link callbacks + // (`dispatch_typed_action(ClaudeCodePanelAction::…)` would silently + // drop when the workspace was the focused view). + ClaudeCodePanel(action) => { + let panel = self.left_panel_view.as_ref(ctx).claude_code_view().clone(); + panel.update(ctx, |view, ctx| { + view.dispatch_action(action, ctx); + }); + } // twarp 07 (PRODUCT §2): toggle the Claude Code tab. Open + focus it // when it isn't the active view; when it already is, return focus to // the previously focused surface (the terminal) rather than diff --git a/app/src/workspace/view/left_panel.rs b/app/src/workspace/view/left_panel.rs index 5d31fbb1e4..35e7372d76 100644 --- a/app/src/workspace/view/left_panel.rs +++ b/app/src/workspace/view/left_panel.rs @@ -1111,6 +1111,10 @@ impl LeftPanelView { &self.warp_drive_view } + pub fn claude_code_view(&self) -> &ViewHandle<ClaudeCodePanelView> { + &self.claude_code_view + } + pub(crate) fn auto_expand_active_file_tree_to_most_recent_directory( &mut self, ctx: &mut ViewContext<Self>,