diff --git a/Cargo.lock b/Cargo.lock index 0fa528d642..c4c63b11df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2638,6 +2638,20 @@ 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 = [ + "anyhow", + "async-process", + "command", + "futures", + "libc", + "log", + "serde", + "serde_json", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -13233,6 +13247,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..e3b696d28c 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 } 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..06a8d7fe88 --- /dev/null +++ b/app/src/claude_code_panel/mod.rs @@ -0,0 +1,1261 @@ +//! The Claude Code left-panel (roadmap feature 07). +//! +//! 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. +//! +//! 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, 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; +use crate::workspace::WorkspaceAction; + +/// 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 { + /// 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 { + 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, + /// `claude` session id, once `system/init` arrived. Used by 7h resume. + #[allow(dead_code)] + session_id: Option, +} + +pub struct ClaudeCodePanelView { + transcript: Transcript, + /// The message input. Real editable buffer; Enter submits, Shift+Enter + /// inserts a newline (PRODUCT §43). + input_editor: ViewHandle, + /// Active session, if any. `None` in the zero state. + session: Option, + /// 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, + /// Indices of expanded thinking cards within `transcript.items()`. + expanded_thinking: Vec, + /// Stored sessions in this panel's cwd (PRODUCT §46). Loaded at view + /// construction and refreshed every time a session ends. + stored_sessions: Vec, + /// 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, + mouse_state_handles: MouseStateHandles, +} + +impl ClaudeCodePanelView { + pub fn new(ctx: &mut ViewContext) -> 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); + // 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, + 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, + resume_button_states, + mouse_state_handles: MouseStateHandles::default(), + } + } + + fn load_sessions() -> Vec { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + 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, + event: &EditorEvent, + ctx: &mut ViewContext, + ) { + // 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) { + 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, + ctx: &mut ViewContext, + ) -> 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::(); + 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; + } + } + }, + |_, _, _| {}, + ); + + // 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(()) + } + + fn apply_event(&mut self, event: TranscriptEvent, ctx: &mut ViewContext) { + // 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) { + // 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.refresh_sessions(); + ctx.notify(); + } + + fn stop(&mut self, ctx: &mut ViewContext) { + 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) { + // PRODUCT §13: ending terminates the process but keeps the transcript + // visible until the next submit clears it. + self.session = None; + self.streaming = false; + self.refresh_sessions(); + ctx.notify(); + } + + fn resume_session(&mut self, session_id: String, ctx: &mut ViewContext) { + // PRODUCT §49: never drive two live processes from one panel. + self.session = None; + self.streaming = false; + // `claude --resume ` 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) { + // 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.refresh_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 { + 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( + "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(); + let refresh = appearance + .ui_builder() + .link( + "Check again".to_owned(), + None, + Some(Box::new(|ctx| { + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + ClaudeCodePanelAction::Refresh, + )); + })), + self.mouse_state_handles.refresh_button.clone(), + ) + .build() + .finish(); + padded_column(vec![title, hint, refresh]) + } + + fn render_header(&self, appearance: &Appearance) -> Box { + 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: "). + 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(8.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(WorkspaceAction::ClaudeCodePanel( + 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(WorkspaceAction::ClaudeCodePanel( + 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() + } + + fn render_input(&self, appearance: &Appearance) -> Box { + // 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 = if self.streaming { + appearance + .ui_builder() + .link( + "Stop".to_owned(), + None, + Some(Box::new(|ctx| { + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + 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() + .link( + label.to_owned(), + None, + Some(Box::new(|ctx| { + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + 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_child(input_view) + .with_child(action) + .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_transcript(&self, appearance: &Appearance) -> Box { + 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 { + let mut children: Vec> = 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 `. 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() + .link( + label, + None, + Some(Box::new(move |ctx| { + ctx.dispatch_typed_action(WorkspaceAction::ClaudeCodePanel( + ClaudeCodePanelAction::ResumeSession(id.clone()), + )); + })), + mouse_state, + ) + .build() + .finish(), + ); + } + } + padded_column(children) + } + + fn render_item( + &self, + idx: usize, + item: &TranscriptItem, + appearance: &Appearance, + ) -> Box { + 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 { + fn ui_name() -> &'static str { + "ClaudeCodePanelView" + } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + // 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 { + let appearance = Appearance::as_ref(app); + 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(); + } + 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() + } +} + +impl Entity for ClaudeCodePanelView { + type Event = (); +} + +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) { + eprintln!("claude_code_panel: dispatch {action:?}"); + match action { + 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.refresh_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(); + } + } + } +} + +impl TypedActionView for ClaudeCodePanelView { + type Action = ClaudeCodePanelAction; + + fn handle_action(&mut self, action: &ClaudeCodePanelAction, ctx: &mut ViewContext) { + self.dispatch_action(action, ctx); + } +} + +// ---------- helpers ---------- + +fn padded_column(children: Vec>) -> Box { + 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, 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 { + 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 { + // 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, + expanded: bool, + appearance: &Appearance, +) -> Box { + 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(WorkspaceAction::ClaudeCodePanel( + 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 { + 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::>() + .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(WorkspaceAction::ClaudeCodePanel( + 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 { + 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 { + // 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 { + 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 { + // 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 { + // 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 { + 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/lib.rs b/app/src/lib.rs index c4e1aebf44..34af1db1c5 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; 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..2b6b0d0aad 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -519,6 +519,13 @@ pub enum WorkspaceAction { ToggleGlobalSearch, OpenGlobalSearch, ToggleConversationListView, + /// 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, @@ -898,6 +905,8 @@ impl WorkspaceAction { | ToggleGlobalSearch | OpenGlobalSearch | ToggleConversationListView + | ToggleClaudeCodePanel + | ClaudeCodePanel(_) | ToggleNotificationMailbox { .. } | OpenLightbox { .. } | UpdateLightboxImage { .. } diff --git a/app/src/workspace/mod.rs b/app/src/workspace/mod.rs index d81aa4ff1d..7c3fa63ab6 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,18 @@ 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). + 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_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..bb09c1292d 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,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. Sits immediately after + // Custom shortcuts in the toolbelt. + views.push(ToolPanelView::ClaudeCode); if WarpDriveSettings::is_warp_drive_enabled(ctx) { views.push(ToolPanelView::WarpDrive); } @@ -19745,6 +19753,32 @@ 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 + // 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_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..35e7372d76 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, @@ -1080,6 +1111,10 @@ impl LeftPanelView { &self.warp_drive_view } + pub fn claude_code_view(&self) -> &ViewHandle { + &self.claude_code_view + } + pub(crate) fn auto_expand_active_file_tree_to_most_recent_directory( &mut self, ctx: &mut ViewContext, @@ -1244,6 +1279,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 +2364,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 +3101,16 @@ impl LeftPanelView { LeftPanelAction::Shortcuts => { active_view_state::set(self, ToolPanelView::Shortcuts, ctx); } + 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. self.shortcut_context_menu_target = None; @@ -3616,6 +3665,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 +3681,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 +3757,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..06f1e6f79b --- /dev/null +++ b/crates/claude_code/Cargo.toml @@ -0,0 +1,18 @@ +[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] +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, + pub resume_session_id: Option, + pub permission_mode: PermissionMode, + pub allowed_tools: Vec, +} + +/// 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 + 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 { + 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>, + buffered: VecDeque, +} + +fn event_stream_from_stdout(stdout: ChildStdout) -> impl Stream + 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) { + 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) { + 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) { + 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) { + // `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::>() + .join("\n"), + _ => String::new(), + } +} + +fn parse_result(value: &Value, out: &mut VecDeque) { + 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 new file mode 100644 index 0000000000..81a54fb49c --- /dev/null +++ b/crates/claude_code/src/lib.rs @@ -0,0 +1,408 @@ +//! `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. + +pub mod driver; +pub mod sessions; + +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/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//*.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 `. 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 ` 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 { + 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 { + 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 { + 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::(&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 e88f3849ed..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:** 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–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–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 @@ -17,13 +17,13 @@ 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). -- [ ] **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. -- [ ] **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//*.jsonl`; resume via `claude --resume `; new-session. No twarp-side session DB. PRODUCT §46–§51. +- [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. +- [x] **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. **(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//*.jsonl`; resume via `claude --resume `; 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 ` (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 de7ab31133..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) | 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–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 | — | — |