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..86a781d48a --- /dev/null +++ b/app/src/claude_code_panel/mod.rs @@ -0,0 +1,701 @@ +//! The Claude Code left-panel (roadmap feature 07, sub-phase **7b**). +//! +//! Hosts the *rendering layer* of Warp's Agent Mode — resurrected by **porting** +//! the deleted `ai_assistant` transcript renderer (`render_message` + the +//! markdown-segment splitter `translate_formatted_text_into_markdown_segments` +//! from commit `fea2f7ea`) and reparenting it onto the thin, twarp-native +//! [`claude_code::Transcript`] model. The headless driver lives in the +//! [`claude_code`] crate; **7b feeds the transcript from a synthetic event +//! source** (no live `claude` process) so the ported renderer is testable +//! end-to-end with no subprocess. 7c swaps the synthetic source for the real +//! [`claude_code::driver`]. +//! +//! Why a port and not a rebuild: PR #67 rebuilt the panel from `Flex`/`Container`/ +//! `Link` primitives and was abandoned for it (TECH.md §Postmortem). Every +//! card/diff/thinking block must trace to a ported leaf or a reused master +//! renderer. 7b brings back the **markdown transcript** leaf: +//! [`render_markdown_body`] is the port of `render_message`'s markdown body +//! (`FormattedTextElement` for prose, a bordered monospace box for fenced code), +//! fed by [`split_markdown_segments`] (the port of the AI-agnostic splitter). +//! The richer tool/diff/thinking/todo cards land in 7d–7f. +//! +//! Dispatch follows [`GlobalSearchView`](crate::workspace::view::global_search): +//! the panel is its own [`TypedActionView`], in-panel clicks dispatch +//! [`ClaudeCodePanelAction`] after an `on_left_mouse_down` focus-grab puts the +//! panel in the responder chain. There is **no** `WorkspaceAction` forwarder — +//! that was the #67 symptom-fix and is deleted. + +use std::ops::Range; + +use claude_code::{Transcript, TranscriptEvent, TranscriptItem}; +use markdown_parser::{parse_markdown, FormattedText, FormattedTextLine}; +use pathfinder_color::ColorU; +use warpui::{ + elements::{ + Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, + Element, EventHandler, Fill, Flex, FormattedTextElement, HighlightedHyperlink, + HyperlinkUrl, Icon, MainAxisSize, MouseStateHandle, ParentElement, Radius, + ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Shrinkable, UniformList, + UniformListState, + }, + presenter::ChildView, + ui_components::components::{UiComponent, UiComponentStyles}, + AppContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext, + ViewHandle, WeakViewHandle, +}; + +use crate::appearance::Appearance; +use crate::editor::{EditorOptions, EditorView, Event as EditorEvent, TextOptions}; +use crate::util::path::resolve_executable; + +/// The executable the panel drives. Resolved on `PATH`; its absence is the +/// unavailable state (PRODUCT §6). +const CLAUDE_BINARY: &str = "claude"; + +/// Avatar glyphs for the message rows (the Agent-Mode shape: icon + body). +const USER_ICON_SVG_PATH: &str = "bundled/svg/user.svg"; +const ASSISTANT_ICON_SVG_PATH: &str = "bundled/svg/agentmode.svg"; + +/// Body / code font sizes, matching the deleted `ai_assistant::transcript` +/// renderer so the ported transcript keeps Agent Mode's proportions. +const BODY_FONT_SIZE: f32 = 13.; +const CODE_FONT_SIZE: f32 = 12.; +const PANEL_LEFT_MARGIN: f32 = 15.; + +/// Actions the panel handles, dispatched the [`GlobalSearchView`] way (the panel +/// is itself the [`TypedActionView`], no `WorkspaceAction` forwarder). 7b keeps +/// this minimal; lifecycle / permissions / resume actions arrive in 7c–7h. +#[derive(Clone, Debug)] +pub enum ClaudeCodePanelAction { + /// Submit the input buffer as a user turn. In 7b this drives the synthetic + /// event source; 7c spawns the real `claude` process (PRODUCT §8). + Submit, + /// Focus the message input — the `on_left_mouse_down` focus-grab that keeps + /// the panel in the responder chain so in-panel dispatches land (TECH §The + /// panel; the fix #67 routed around with a forwarder). + FocusInput, + /// Open a URL clicked inside assistant markdown (PRODUCT §18 links). + OpenUrl(HyperlinkUrl), + /// Re-check `claude` availability — the unavailable state's "Check again" + /// (PRODUCT §6: "re-checked each time the panel is opened"). + Refresh, +} + +pub struct ClaudeCodePanelView { + /// The conversation the panel renders. 7b populates it from a synthetic + /// source on submit; the live driver fills it in 7c. + transcript: Transcript, + /// The message input. Enter submits; Shift+Enter inserts a newline (the + /// full §43 semantics are validated in 7g, but the editor gives both now). + input_editor: ViewHandle, + /// Weak self-handle so the [`UniformList`] `build_items` closure can render + /// rows on demand (the [`GlobalSearchView`] virtualized-list pattern). + handle: WeakViewHandle, + /// Virtualized transcript list state (PRODUCT §21 — large sessions stay + /// responsive because only visible rows render). + uniform_list_state: UniformListState, + scroll_state: ScrollStateHandle, + /// Stable mouse-state handles kept across renders so a click's + /// mousedown/mouseup hit the same handle. + submit_button: MouseStateHandle, + refresh_button: MouseStateHandle, +} + +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); + input_editor.update(ctx, |editor, ctx| { + editor.set_placeholder_text("Message Claude Code…", ctx); + }); + Self { + transcript: Transcript::new(), + input_editor, + handle: ctx.handle(), + uniform_list_state: UniformListState::new(), + scroll_state: ScrollStateHandle::default(), + submit_button: MouseStateHandle::default(), + refresh_button: MouseStateHandle::default(), + } + } + + /// Whether the `claude` CLI is resolvable on `PATH` right now (PRODUCT §6). + fn claude_available() -> bool { + resolve_executable(CLAUDE_BINARY).is_some() + } + + 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. + /// + /// 7b has **no driver**: instead of spawning `claude`, it applies a + /// representative sequence of [`TranscriptEvent`]s (the synthetic source) so + /// the ported renderer is exercised end-to-end. 7c replaces + /// [`synthetic_reply`] with the real `claude_code::driver` event stream and + /// the `apply_event` pump (TECH §Re-derived sub-phase plan). + fn submit(&mut self, ctx: &mut ViewContext) { + let text = self + .input_editor + .read(ctx, |editor, ctx| editor.buffer_text(ctx).trim().to_owned()); + if text.is_empty() { + // PRODUCT §44: empty / whitespace-only messages are a no-op. + return; + } + self.transcript + .apply(TranscriptEvent::UserMessage(text.clone())); + for event in synthetic_reply(&text) { + self.transcript.apply(event); + } + self.input_editor + .update(ctx, |editor, ctx| editor.clear_buffer(ctx)); + ctx.notify(); + } + + fn render_header(&self, appearance: &Appearance) -> Box { + // PRODUCT §4: the header shows the session cwd. Net-new chrome (the + // Agent-block header was service-coupled; TECH matrix marks it + // do-NOT-port). 7b shows the current dir; 7c shows the live session cwd. + let cwd = std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "—".to_owned()); + let title = appearance + .ui_builder() + .span("Claude Code".to_owned()) + .build() + .finish(); + let dir = appearance + .ui_builder() + .span(cwd) + .with_soft_wrap() + .build() + .finish(); + Container::new( + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Start) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(2.) + .with_child(title) + .with_child(dir) + .finish(), + ) + .with_padding_left(10.) + .with_padding_right(10.) + .with_padding_top(8.) + .with_padding_bottom(8.) + .finish() + } + + fn render_body(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + if self.transcript.is_empty() { + // PRODUCT §5: zero state when no session has produced anything. + return render_zero_state(appearance); + } + self.render_transcript(app) + } + + /// Render the transcript into a [`UniformList`] (PRODUCT §21) wrapped in a + /// vertical [`Scrollable`], mirroring [`GlobalSearchView::render_results`]. + /// Bottom-stick auto-scroll (PRODUCT §22) layers on in 7c when content + /// actually streams; 7b's synthetic transcript is static. + fn render_transcript(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let handle = self.handle.clone(); + let item_count = self.transcript.items().len(); + + let build_items = move |range: Range, app: &AppContext| { + let view_handle = handle + .upgrade(app) + .expect("ClaudeCodePanelView handle should be upgradeable"); + let view = view_handle.as_ref(app); + let appearance = Appearance::as_ref(app); + (range.start..range.end) + .map(|idx| render_item(&view.transcript.items()[idx], appearance)) + .collect::>() + .into_iter() + }; + + let list = UniformList::new(self.uniform_list_state.clone(), item_count, build_items); + Scrollable::vertical( + self.scroll_state.clone(), + list.finish_scrollable(), + ScrollbarWidth::Auto, + theme.nonactive_ui_detail().into(), + theme.active_ui_detail().into(), + Fill::None, + ) + .with_overlayed_scrollbar() + .finish() + } + + fn render_input(&self, appearance: &Appearance) -> Box { + let theme = appearance.theme(); + let input_view = Container::new(ChildView::new(&self.input_editor).finish()) + .with_padding_top(6.) + .with_padding_bottom(6.) + .with_padding_left(10.) + .with_padding_right(10.) + .with_background_color(theme.surface_3().into_solid()) + .finish(); + + // PRODUCT §5: "Start session" in the zero state, "Send" once a + // conversation exists. Both dispatch `Submit` (synthetic in 7b). + let label = if self.transcript.is_empty() { + "Start session" + } else { + "Send" + }; + let action = appearance + .ui_builder() + .link( + label.to_owned(), + None, + Some(Box::new(|ctx| { + ctx.dispatch_typed_action(ClaudeCodePanelAction::Submit); + })), + self.submit_button.clone(), + ) + .build() + .finish(); + + Container::new( + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(8.) + .with_child(input_view) + .with_child(action) + .finish(), + ) + .with_padding_left(10.) + .with_padding_right(10.) + .with_padding_top(6.) + .with_padding_bottom(10.) + .finish() + } + + fn render_unavailable_state(&self, appearance: &Appearance) -> Box { + // PRODUCT §6: replaces the whole panel; names the missing binary, gives + // an install hint, no input affordances. + 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." + .to_owned(), + ) + .with_soft_wrap() + .build() + .finish(); + let check = appearance + .ui_builder() + .link( + "Check again".to_owned(), + None, + Some(Box::new(|ctx| { + ctx.dispatch_typed_action(ClaudeCodePanelAction::Refresh); + })), + self.refresh_button.clone(), + ) + .build() + .finish(); + Container::new( + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(10.) + .with_child(title) + .with_child(hint) + .with_child(check) + .finish(), + ) + .with_uniform_padding(15.) + .finish() + } +} + +impl Entity for ClaudeCodePanelView { + type Event = (); +} + +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. Focusing a + // child keeps the panel itself in the responder chain, so in-panel + // `ClaudeCodePanelAction` dispatches reach `handle_action` below. + if focus_ctx.is_self_focused() { + ctx.focus(&self.input_editor); + } + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + // PRODUCT §6: the unavailable state replaces everything. + if !Self::claude_available() { + return self.render_unavailable_state(appearance); + } + let body = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Max) + .with_child(self.render_header(appearance)) + .with_child(Shrinkable::new(1.0, self.render_body(app)).finish()) + .with_child(self.render_input(appearance)) + .finish(); + + // The focus-grab (TECH §The panel): any click on empty panel chrome + // focuses the input, guaranteeing the panel is the dispatch target for + // subsequent in-panel actions — what #67 worked around with a forwarder. + EventHandler::new(body) + .on_left_mouse_down(|ctx, _, _| { + ctx.dispatch_typed_action(ClaudeCodePanelAction::FocusInput); + DispatchEventResult::StopPropagation + }) + .finish() + } +} + +impl TypedActionView for ClaudeCodePanelView { + type Action = ClaudeCodePanelAction; + + fn handle_action(&mut self, action: &ClaudeCodePanelAction, ctx: &mut ViewContext) { + match action { + ClaudeCodePanelAction::Submit => self.submit(ctx), + ClaudeCodePanelAction::FocusInput => ctx.focus(&self.input_editor), + ClaudeCodePanelAction::OpenUrl(url) => ctx.open_url(&url.url), + // PRODUCT §6: render re-checks availability, so a notify suffices. + ClaudeCodePanelAction::Refresh => ctx.notify(), + } + } +} + +// ---------- transcript rendering (the ported leaf) ---------- + +/// Bridge dispatch (TECH §The bridge): one arm per [`TranscriptItem`]. 7b +/// renders the markdown transcript (User / Assistant). The rich tool, diff, +/// thinking and todo cards are 7d–7f; the 7b synthetic source emits none of +/// them, so those arms render a minimal themed placeholder rather than crash — +/// the model contract already carries the variants. +fn render_item(item: &TranscriptItem, appearance: &Appearance) -> Box { + match item { + TranscriptItem::User(text) => render_message_row( + USER_ICON_SVG_PATH, + text, + appearance.theme().surface_1().into_solid(), + appearance, + ), + TranscriptItem::Assistant { text, .. } => render_message_row( + ASSISTANT_ICON_SVG_PATH, + text, + appearance.theme().surface_2().into_solid(), + appearance, + ), + TranscriptItem::Notice(message) => render_notice(message, appearance), + TranscriptItem::Error(message) => render_error(message, appearance), + // 7d–7f bring back the rich tool/diff/thinking/todo cards (ported from + // the `inline_action` chrome + feature 05's diff renderer). Not reached + // by 7b's synthetic source. + TranscriptItem::Thinking { .. } + | TranscriptItem::Tool { .. } + | TranscriptItem::Todos(_) + | TranscriptItem::Permission { .. } => render_pending_card(item, appearance), + } +} + +/// Port of `ai_assistant::transcript::render_message`: an icon + a markdown body +/// on a themed surface. User and assistant rows differ only by glyph and +/// background, so the message turns are visually distinct (PRODUCT §16). +fn render_message_row( + icon_svg: &'static str, + text: &str, + background: ColorU, + appearance: &Appearance, +) -> Box { + let theme = appearance.theme(); + let text_color = theme.main_text_color(background.into()).into_solid(); + let icon = ConstrainedBox::new(Icon::new(icon_svg, text_color).finish()) + .with_height(16.) + .with_width(16.) + .finish(); + + let row = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_child( + Container::new(icon) + .with_margin_right(12.) + .with_margin_top(3.) + .finish(), + ) + .with_child( + Shrinkable::new( + 1., + Container::new(render_markdown_body(text, text_color, appearance)).finish(), + ) + .finish(), + ); + + Container::new(row.finish()) + .with_background_color(background) + .with_padding_left(PANEL_LEFT_MARGIN) + .with_padding_top(16.) + .with_padding_bottom(16.) + .with_padding_right(20.) + .finish() +} + +/// Port of `render_message`'s markdown body: render each [`MarkdownSegment`] — +/// prose via [`FormattedTextElement`] (feature 03's stack), fenced code via a +/// bordered monospace box. PRODUCT §18 (markdown), §53 (defensive: fall back to +/// plain wrapped text if the markdown does not parse). +fn render_markdown_body( + text: &str, + text_color: ColorU, + appearance: &Appearance, +) -> Box { + let theme = appearance.theme(); + let inline_code_bg = theme.surface_3().into_solid(); + + let Ok(formatted) = parse_markdown(text) else { + return appearance + .ui_builder() + .wrappable_text(text.to_owned(), true) + .with_style(UiComponentStyles { + font_size: Some(BODY_FONT_SIZE), + ..Default::default() + }) + .build() + .finish(); + }; + + let mut column = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min); + for segment in split_markdown_segments(formatted) { + let child = match segment { + MarkdownSegment::Prose(formatted_text) => FormattedTextElement::new( + formatted_text, + BODY_FONT_SIZE, + appearance.ui_font_family(), + appearance.monospace_font_family(), + text_color, + HighlightedHyperlink::default(), + ) + .with_inline_code_properties( + Some(theme.nonactive_ui_text_color().into()), + Some(inline_code_bg), + ) + .register_default_click_handlers(|url, ctx, _| { + ctx.dispatch_typed_action(ClaudeCodePanelAction::OpenUrl(url)); + }) + .finish(), + MarkdownSegment::Code(code) => render_code_block(&code.code, appearance), + }; + column.add_child(child); + } + column.finish() +} + +/// Port of `ai_assistant::transcript`'s code-block branch, minus the Warp-AI +/// affordances (paste-in-terminal / save-as-workflow / per-block selection) +/// that don't apply to a read-only Claude Code transcript: a bordered, +/// rounded, monospace box. The copy affordance is a later refinement. +fn render_code_block(code: &str, appearance: &Appearance) -> Box { + let theme = appearance.theme(); + Container::new( + appearance + .ui_builder() + .wrappable_text(code.to_owned(), true) + .with_style(UiComponentStyles { + font_family_id: Some(appearance.monospace_font_family()), + font_size: Some(CODE_FONT_SIZE), + ..Default::default() + }) + .build() + .finish(), + ) + .with_uniform_padding(12.) + .with_border(Border::all(1.).with_border_fill(theme.outline())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) + .with_margin_top(10.) + .with_margin_bottom(10.) + .finish() +} + +/// Out-of-band notice (turn interrupted / session ended) — a subtle themed row. +fn render_notice(message: &str, appearance: &Appearance) -> Box { + Container::new( + appearance + .ui_builder() + .span(message.to_owned()) + .with_soft_wrap() + .build() + .finish(), + ) + .with_padding_top(6.) + .with_padding_bottom(6.) + .with_padding_left(PANEL_LEFT_MARGIN) + .with_padding_right(20.) + .finish() +} + +/// Error surfaced verbatim from `claude` (PRODUCT §55) on a themed surface. The +/// copy affordance lands with the live driver in 7c. +fn render_error(message: &str, appearance: &Appearance) -> Box { + Container::new( + appearance + .ui_builder() + .span(format!("Error: {message}")) + .with_soft_wrap() + .build() + .finish(), + ) + .with_background_color(appearance.theme().surface_2().into_solid()) + .with_padding_top(8.) + .with_padding_bottom(8.) + .with_padding_left(PANEL_LEFT_MARGIN) + .with_padding_right(20.) + .finish() +} + +/// Placeholder for transcript variants whose rich cards land in 7d–7f. Kept +/// minimal and clearly labelled; never reached by 7b's synthetic source. +fn render_pending_card(item: &TranscriptItem, appearance: &Appearance) -> Box { + let kind = match item { + TranscriptItem::Thinking { .. } => "Thinking", + TranscriptItem::Tool { .. } => "Tool call", + TranscriptItem::Todos(_) => "Task list", + TranscriptItem::Permission { .. } => "Permission request", + _ => "Item", + }; + render_notice( + &format!("{kind} (rendered in a later sub-phase)"), + appearance, + ) +} + +/// A contiguous run of a message's markdown: prose (rendered via +/// [`FormattedTextElement`]) or a fenced code block (rendered specially). +/// +/// Ported and simplified from `ai_assistant::utils::MarkdownSegment`: the +/// Claude Code transcript is read-only, so the per-code-block selection index +/// and Warp-AI action mouse handles are dropped — only the code string is kept. +enum MarkdownSegment { + Prose(FormattedText), + Code(markdown_parser::CodeBlockText), +} + +/// Port of `ai_assistant::utils::translate_formatted_text_into_markdown_segments` +/// (AI-agnostic): split a parsed [`FormattedText`] into code-block vs contiguous +/// non-code runs so code blocks can render in their own box. +fn split_markdown_segments(formatted: FormattedText) -> Vec { + let mut segments = Vec::new(); + let mut running_prose: Vec = Vec::new(); + + for line in formatted.lines { + match line { + FormattedTextLine::CodeBlock(mut code) => { + if !running_prose.is_empty() { + segments.push(MarkdownSegment::Prose(FormattedText::new_trimmed( + std::mem::take(&mut running_prose), + ))); + } + code.code = code.code.trim().to_string(); + segments.push(MarkdownSegment::Code(code)); + } + other => running_prose.push(other), + } + } + if !running_prose.is_empty() { + segments.push(MarkdownSegment::Prose(FormattedText::new_trimmed( + running_prose, + ))); + } + segments +} + +/// The 7b **synthetic event source**: stands in for the live driver so the +/// ported renderer can be exercised end-to-end with no `claude` process. Returns +/// a representative markdown assistant reply (heading, list, inline code, a +/// fenced code block, a link) that demonstrates the Agent-Mode transcript shape +/// — the 7b acceptance gate. 7c deletes this and feeds real +/// [`claude_code::driver`] events instead (TECH §Re-derived sub-phase plan). +fn synthetic_reply(user_text: &str) -> Vec { + let markdown = format!( + "Here's how I'd approach **{user_text}**.\n\n\ + ## Plan\n\ + - Inspect the project layout with `ls` and `cat`\n\ + - Make the change in the relevant module\n\ + - Re-run the tests with `cargo test`\n\n\ + Example command:\n\n\ + ```bash\n\ + cargo test -p claude_code\n\ + ```\n\n\ + See the [Claude Code docs](https://docs.claude.com/en/docs/claude-code) for more. \ + This reply is the ported Agent-Mode transcript renderer (sub-phase 7b); a live \ + `claude` session replaces it in 7c." + ); + vec![ + TranscriptEvent::AssistantTextDelta { text: markdown }, + TranscriptEvent::AssistantTextDone, + ] +} + +/// Zero state (PRODUCT §5): a short explanation. The single-line message input +/// and "Start session" affordance live in the always-present input row below. +/// The "Resume…" entry point (PRODUCT §46) arrives with the session list in 7h. +fn render_zero_state(appearance: &Appearance) -> Box { + let explanation = appearance + .ui_builder() + .span( + "Type a message below 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." + .to_owned(), + ) + .with_soft_wrap() + .build() + .finish(); + Container::new( + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min) + .with_spacing(10.) + .with_child(explanation) + .finish(), + ) + .with_uniform_padding(15.) + .finish() +} diff --git a/app/src/lib.rs b/app/src/lib.rs index c4e1aebf44..d91d29d0db 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -49,6 +49,10 @@ mod banner; mod billing; mod changelog_model; mod chip_configurator; +// twarp 07: Claude Code left-panel. Always-on (no feature flag — acceptable on +// a personal fork; degrades to the unavailable state when `claude` is off PATH, +// PRODUCT §6). See TECH.md §Feature flag & rollout. +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..0710809ae6 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -519,6 +519,11 @@ pub enum WorkspaceAction { ToggleGlobalSearch, OpenGlobalSearch, ToggleConversationListView, + /// twarp 07: toggle the Claude Code left-panel tab (PRODUCT §2). The panel + /// dispatches its own `ClaudeCodePanelAction`s the `GlobalSearchView` way + /// (no `WorkspaceAction` forwarder — that was the PR #67 mistake; TECH.md + /// §The panel). + ToggleClaudeCodePanel, /// Open the Build Plan Migration Modal (for debugging) #[cfg(debug_assertions)] OpenBuildPlanMigrationModal, @@ -898,6 +903,7 @@ impl WorkspaceAction { | ToggleGlobalSearch | OpenGlobalSearch | ToggleConversationListView + | ToggleClaudeCodePanel | ToggleNotificationMailbox { .. } | OpenLightbox { .. } | UpdateLightboxImage { .. } diff --git a/app/src/workspace/mod.rs b/app/src/workspace/mod.rs index d81aa4ff1d..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..3514b56a81 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,20 @@ impl TypedActionView for Workspace { ); } } + // twarp 07 (PRODUCT §2): toggle the Claude Code tab. Open + focus it + // when it isn't the active view; when it already is, return focus to + // the previously focused surface (the terminal) rather than + // collapsing the whole left panel out from under other tabs. + ToggleClaudeCodePanel => { + 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..045c59ecb1 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,10 @@ 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 (always-on; see TECH.md §Feature flag). + /// 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 +271,8 @@ pub struct LeftPanelView { mouse_state_handles: MouseStateHandles, close_button_mouse_state: MouseStateHandle, warp_drive_view: ViewHandle, + // twarp 07: the Claude Code panel view (always-on; see TECH.md §Feature flag). + claude_code_view: ViewHandle, // twarp: 2c-d — conversation_list_view removed active_view: active_view_state::ActiveViewState, toolbelt_buttons: Vec, @@ -762,6 +774,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 +855,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 +991,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 +1112,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 +1280,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 +2365,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 +3102,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 +3666,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 +3682,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 +3758,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 76753bfff6..138a2475f0 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 (re-spec PR [#68](https://github.com/timomak/twarp/pull/68) open) — **regressed from `impl-in-review`** -**Spec PRs:** [#66](https://github.com/timomak/twarp/pull/66) (PRODUCT.md + TECH.md, merged) · re-spec [#68](https://github.com/timomak/twarp/pull/68) (port-and-adapt plan, open) -**Impl PRs:** [#67](https://github.com/timomak/twarp/pull/67) — **abandoned** (rebuilt from primitives instead of porting; see postmortem). Owner to close. +**Phase:** impl-in-review (7b PR [#69](https://github.com/timomak/twarp/pull/69) open) +**Spec PRs:** [#66](https://github.com/timomak/twarp/pull/66) (PRODUCT.md + TECH.md, merged) · re-spec [#68](https://github.com/timomak/twarp/pull/68) (port-and-adapt plan, merged) +**Impl PRs:** [#67](https://github.com/timomak/twarp/pull/67) — **abandoned** (rebuilt from primitives instead of porting; superseded by the 7b port). Owner to close. · 7b [#69](https://github.com/timomak/twarp/pull/69) — panel shell + ported transcript, synthetic source (open) ## Scope @@ -25,7 +25,7 @@ PR #67 bundled 7b–7h into one PR and **rebuilt the panel from GPUI primitives* The previous 7b–7h checklist tracked behavior buckets and was marked done in #67; those ticks are **cleared** because the work shipped against the wrong approach. The new split tracks *which leaf is brought back, what it bridges to, and what stub it needs* (TECH.md §Re-derived sub-phase plan). 7a stays done (the audit/gate); it is amended by this re-spec. - [x] **7a — Audit + TECH.md (amended by this re-spec).** Gate resolved: **per-component port-and-adapt** from `fea2f7ea`; reparent leaf rendering onto the thin `claude_code::Transcript` model; do **not** `git restore` the service-coupled pieces; rewrite any leaf whose port drags in more `crate::ai::` coupling than rebuilding. Driver in `crates/claude_code`; UI in `app/src/claude_code_panel/`. Re-spec adds the per-file decision matrix, the bridge spec, and the "visually matches Agent Mode" acceptance gate. (Spec [#66], re-spec [#68].) -- [ ] **7b — Panel shell + ported transcript, stub event source.** Keep registration scaffolding; replace #67's primitive body with the ported transcript renderer (`ai_assistant/transcript.rs::render_message` + the `markdown_parser`→`FormattedTextElement` stack) inside a `UniformList`, fed a **synthetic** `Transcript` (no driver). Dispatch wired the `GlobalSearchView` way; `WorkspaceAction::ClaudeCodePanel` forwarder deleted. Zero + unavailable states. **Acceptance: sample transcript renders in Agent-Mode shape and visually matches `warp.dev/agents/claude-code`.** PRODUCT §1–§7, §16–§20, §60. +- [x] **7b — Panel shell + ported transcript, stub event source.** (PR [#69](https://github.com/timomak/twarp/pull/69), in review.) Kept registration scaffolding; replaced #67's primitive body with the ported transcript renderer (`ai_assistant/transcript.rs::render_message` + the `markdown_parser`→`FormattedTextElement` stack) inside a `UniformList`, fed a **synthetic** `Transcript` (no driver). Dispatch wired the `GlobalSearchView` way (panel is its own `TypedActionView` + `on_left_mouse_down` focus-grab); `WorkspaceAction::ClaudeCodePanel` forwarder deleted. Zero + unavailable states. **Acceptance: sample transcript renders in Agent-Mode shape and visually matches `warp.dev/agents/claude-code`** — owner smoke test (PRODUCT smoke 1–5 + §28 gate). `cargo test -p claude_code` (19), `cargo check`/`clippy`/`fmt` clean. PRODUCT §1–§7, §16–§20, §60. - [ ] **7c — Live driver bridge.** Connect the kept `claude_code::driver` to the ported transcript via the per-`TranscriptItem` bridge dispatch; remove the stub source. Streaming/Stop/lifecycle/teardown. PRODUCT §8–§22, §52–§57. - [ ] **7d — Tool cards.** Port `inline_action_icons` + `inline_action_header` + `requested_action` (co-port `WithContentItemSpacing`); bridge `TranscriptItem::Tool` → `RenderableAction` cards with per-tool summary + generic fallback for unmapped/`mcp__*`. PRODUCT §23–§29. - [ ] **7e — Diff cards.** Synthesize a unified diff (kept `diff_for_tool`) and render **read-only via feature 05 / `crate::code::inline_diff::InlineDiffView`** (not `code_diff_view.rs` chrome, not plain spans). PRODUCT §30–§33. @@ -38,7 +38,7 @@ The previous 7b–7h checklist tracked behavior buckets and was marked done in # - Closest visual reference is Warp's official Agent Mode UI ([warp.dev/agents/claude-code](https://www.warp.dev/agents/claude-code)). The twarp panel should render the **same shape** — this is the acceptance gate the port-and-adapt approach is built around (the gate #67 failed). - Each card/diff/thinking block in every impl PR must trace to a **ported leaf** or a **reused master renderer**, never to a fresh `Flex`/`Container`/`Link` tree. Review against TECH.md §Per-file decision matrix. - **Pin the claude-code version** the driver is tested against; golden stream-json transcripts are `crates/claude_code` parser fixtures (already in the kept crate). -- **Feature flag:** #67 removed `FeatureFlag::ClaudeCodePanel` (panel always-on). 7b decides whether to restore flag-gating for dogfood rollout or stay always-on (acceptable on a personal fork). Record the choice here (TECH.md §Feature flag & rollout). +- **Feature flag — DECIDED (7b): always-on, no flag.** Acceptable on a personal fork (the owner is the only user), and the panel degrades cleanly to the unavailable state when `claude` is off `PATH` (§6), so always-on doesn't break a machine without Claude Code installed. `compute_left_panel_views` pushes the tab unconditionally. Re-add `FeatureFlag::ClaudeCodePanel` only if the panel ever ships beyond the fork (TECH.md §Feature flag & rollout). - Framing matters in STATUS / PR descriptions: feature 02 removed Warp's *AI service*; feature 07 brings back the *rendering layer only*, driven by an external CLI the user already pays for. No LLM connection, no billing, no cloud sync comes back. ## Why this is feature 07 (before rebrand) diff --git a/roadmap/ROADMAP.md b/roadmap/ROADMAP.md index 8002892bbd..fa5c6f16a1 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), respec [#68](https://github.com/timomak/twarp/pull/68) | — | +| 07 | [Claude Code panel](07-claude-code-panel/STATUS.md) | impl-in-review | [#66](https://github.com/timomak/twarp/pull/66), respec [#68](https://github.com/timomak/twarp/pull/68) | 7b [#69](https://github.com/timomak/twarp/pull/69) | | 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 | — | — |