From 3e93f0138ccfbb05c8125705ba52dfe54534f0b1 Mon Sep 17 00:00:00 2001 From: Timomak Date: Tue, 2 Jun 2026 15:51:12 +0200 Subject: [PATCH 1/3] [twarp 07b] Claude Code panel: main-pane host + claude-at-submit trigger Relocate the transcript renderer #69 landed in the rejected left sidebar into a first-class main-content pane (IPaneType::ClaudeCode), add the conservative `claude`-at-submit terminal trigger that opens it, and delete the #69 sidebar host. Per the main-pane re-spec (#70). - ClaudeCodeView (BackingView) is the git-mv'd #69 renderer; transcript + docked composer are the pane body, the "Claude Code" + cwd header moves to render_header_content. All render leaves + the synthetic source are unchanged. - ClaudeCodePane (PaneContent) mirrors NetworkLogPane; LeafContents::ClaudeCode is non-persisted (no transcript store; resume via `claude --resume`, 7h). - Trigger mirrors the OpenCodeInWarp chain: Input intercepts a bare top-level `claude` (tokenized via warp_completer; pipes/chains/paths/off-PATH run raw), emits OpenClaudeCodePane through input::Event -> view::Event -> pane_group -> Workspace::open_claude_code_pane. Intercepted block shows a brief toast. - Sidebar host removed: ToolPanelView::ClaudeCode, toolbelt button, LeftPanelDisplayedTab persistence, ToggleClaudeCodePanel action + the cmd-alt-K binding. cargo check/clippy/fmt clean; claude_code 19/19 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/app_state.rs | 13 +- .../mod.rs => claude_code_view.rs} | 345 +++++++++++------- app/src/launch_configs/launch_config.rs | 2 + app/src/lib.rs | 10 +- app/src/pane_group/mod.rs | 16 + app/src/pane_group/pane/claude_code_pane.rs | 138 +++++++ app/src/pane_group/pane/mod.rs | 21 ++ app/src/pane_group/pane/terminal_pane.rs | 7 + app/src/persistence/sqlite.rs | 6 +- app/src/terminal/input.rs | 79 +++- app/src/terminal/view.rs | 15 + app/src/util/bindings.rs | 14 +- app/src/workspace/action.rs | 9 +- app/src/workspace/mod.rs | 30 +- app/src/workspace/view.rs | 62 ++-- app/src/workspace/view/left_panel.rs | 73 +--- app/src/workspace/view/vertical_tabs.rs | 3 + 17 files changed, 583 insertions(+), 260 deletions(-) rename app/src/{claude_code_panel/mod.rs => claude_code_view.rs} (68%) create mode 100644 app/src/pane_group/pane/claude_code_pane.rs diff --git a/app/src/app_state.rs b/app/src/app_state.rs index eb5192bd8d..da49f982d9 100644 --- a/app/src/app_state.rs +++ b/app/src/app_state.rs @@ -733,6 +733,11 @@ pub enum LeafContents { /// The in-app network log pane. Not persisted across restarts because the /// backing log is an in-memory ring buffer that starts empty on launch. NetworkLog, + /// twarp 07 (7b): a Claude Code pane. Not persisted across restarts — a + /// restored pane can't replay a live `claude` process and twarp keeps no + /// transcript store; sessions are reopened via `claude --resume` from the + /// 7h session list. + ClaudeCode, /// An entrypoint pane type to launch other pane types from a search palette. The default view /// when creating a tab. Welcome { @@ -759,6 +764,8 @@ impl LeafContents { // starts empty on launch; persisting would also regress back to // an on-disk log via the app-state database. LeafContents::NetworkLog => false, + // twarp 07 (7b): see the variant doc — never persisted. + LeafContents::ClaudeCode => false, LeafContents::Terminal(_) | LeafContents::Notebook(_) | LeafContents::AIDocument(_) @@ -891,9 +898,8 @@ 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, + // twarp 07 (7b): no Claude Code sidebar tab to persist — it's a + // main-content pane opened by typing `claude` (re-spec #70). ConversationListView, } @@ -904,7 +910,6 @@ 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_view.rs similarity index 68% rename from app/src/claude_code_panel/mod.rs rename to app/src/claude_code_view.rs index 86a781d48a..09170eab97 100644 --- a/app/src/claude_code_panel/mod.rs +++ b/app/src/claude_code_view.rs @@ -1,14 +1,22 @@ -//! The Claude Code left-panel (roadmap feature 07, sub-phase **7b**). +//! The Claude Code **main-content pane** view (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`]. +//! This is the [`BackingView`] hosted by [`ClaudeCodePane`](crate::pane_group::pane::claude_code_pane::ClaudeCodePane): +//! a first-class pane (like an editor or terminal tab) opened by typing +//! `claude` in a terminal. It 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`]. +//! +//! History: PR #69 landed this exact renderer inside a **left sidebar**, which +//! the owner rejected on sight. The re-spec (#70) moved it into this pane and +//! repurposed the sidebar to a read-only session list (7h). The *rendering* +//! carried over unchanged; only the host surface and entry point changed. //! //! 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 @@ -20,12 +28,13 @@ //! 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 — +//! the view is its own [`TypedActionView`], in-pane clicks dispatch +//! [`ClaudeCodeViewAction`] after an `on_left_mouse_down` focus-grab puts the +//! view in the responder chain. There is **no** `WorkspaceAction` forwarder — //! that was the #67 symptom-fix and is deleted. use std::ops::Range; +use std::path::PathBuf; use claude_code::{Transcript, TranscriptEvent, TranscriptItem}; use markdown_parser::{parse_markdown, FormattedText, FormattedTextLine}; @@ -39,19 +48,28 @@ use warpui::{ UniformListState, }, presenter::ChildView, + text_layout::ClipConfig, ui_components::components::{UiComponent, UiComponentStyles}, - AppContext, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext, - ViewHandle, WeakViewHandle, + AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View, + ViewContext, ViewHandle, WeakViewHandle, }; use crate::appearance::Appearance; use crate::editor::{EditorOptions, EditorView, Event as EditorEvent, TextOptions}; +use crate::pane_group::focus_state::PaneFocusHandle; +use crate::pane_group::{ + pane::view::{self, HeaderContent, StandardHeader, StandardHeaderOptions}, + BackingView, PaneConfiguration, PaneEvent, +}; use crate::util::path::resolve_executable; -/// The executable the panel drives. Resolved on `PATH`; its absence is the -/// unavailable state (PRODUCT §6). +/// The executable the pane drives. Resolved on `PATH`; its absence is the +/// unavailable state (PRODUCT §4). const CLAUDE_BINARY: &str = "claude"; +/// Pane title (PRODUCT §5) — drives the tab label via [`PaneConfiguration`]. +const PANE_TITLE: &str = "Claude Code"; + /// 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"; @@ -60,38 +78,54 @@ const ASSISTANT_ICON_SVG_PATH: &str = "bundled/svg/agentmode.svg"; /// 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.; +const TRANSCRIPT_LEFT_MARGIN: f32 = 15.; + +/// Events the pane view emits to its host [`ClaudeCodePane`]. 7b only needs +/// `Close` (so the pane-header close button works); 7c adds session-lifecycle +/// events as the live driver lands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaudeCodeViewEvent { + Pane(PaneEvent), +} -/// Actions the panel handles, dispatched the [`GlobalSearchView`] way (the panel +/// Actions the view handles, dispatched the [`GlobalSearchView`] way (the view /// 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 { +pub enum ClaudeCodeViewAction { /// Submit the input buffer as a user turn. In 7b this drives the synthetic - /// event source; 7c spawns the real `claude` process (PRODUCT §8). + /// event source; 7c spawns the real `claude` process (PRODUCT §6). 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). + /// the view in the responder chain so in-pane dispatches land (TECH §The + /// pane; the fix #67 routed around with a forwarder). FocusInput, - /// Open a URL clicked inside assistant markdown (PRODUCT §18 links). + /// Open a URL clicked inside assistant markdown (PRODUCT §13 links). OpenUrl(HyperlinkUrl), /// Re-check `claude` availability — the unavailable state's "Check again" - /// (PRODUCT §6: "re-checked each time the panel is opened"). + /// (PRODUCT §4). Refresh, } -pub struct ClaudeCodePanelView { - /// The conversation the panel renders. 7b populates it from a synthetic +pub struct ClaudeCodeView { + /// The conversation the pane 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). + /// The message input. Enter submits; Shift+Enter inserts a newline. input_editor: ViewHandle, + /// Pane chrome state (tab title). Owned here, handed to [`PaneView`] by the + /// [`ClaudeCodePane`] wrapper. + pane_configuration: ModelHandle, + /// Tracks whether the hosting pane is focused (set by the pane framework via + /// [`BackingView::set_focus_handle`]). + focus_handle: Option, + /// The working directory of the terminal that opened the pane (PRODUCT §4), + /// shown in the header. 7c spawns `claude` here. + cwd: Option, /// 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 + /// Virtualized transcript list state (PRODUCT §14 — large sessions stay /// responsive because only visible rows render). uniform_list_state: UniformListState, scroll_state: ScrollStateHandle, @@ -101,8 +135,18 @@ pub struct ClaudeCodePanelView { refresh_button: MouseStateHandle, } -impl ClaudeCodePanelView { - pub fn new(ctx: &mut ViewContext) -> Self { +impl ClaudeCodeView { + /// Build the pane view. + /// + /// `initial_prompt` is the trailing positional from `claude ` + /// (PRODUCT §2): when present it seeds the first user turn. `cwd` is the + /// terminal's working directory (PRODUCT §4). In 7b there is no driver, so + /// a non-empty prompt is rendered through the synthetic source. + pub fn new( + initial_prompt: Option, + cwd: Option, + ctx: &mut ViewContext, + ) -> Self { let input_editor = ctx.add_typed_action_view(|ctx| { let appearance = Appearance::as_ref(ctx); let options = EditorOptions { @@ -117,9 +161,29 @@ impl ClaudeCodePanelView { input_editor.update(ctx, |editor, ctx| { editor.set_placeholder_text("Message Claude Code…", ctx); }); + + let pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new(PANE_TITLE)); + + // Seed the synthetic first turn from `claude ` (PRODUCT §2). 7c + // forwards this to the real `claude` process instead. + let mut transcript = Transcript::new(); + if let Some(prompt) = initial_prompt + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()) + { + transcript.apply(TranscriptEvent::UserMessage(prompt.to_owned())); + for event in synthetic_reply(prompt) { + transcript.apply(event); + } + } + Self { - transcript: Transcript::new(), + transcript, input_editor, + pane_configuration, + focus_handle: None, + cwd, handle: ctx.handle(), uniform_list_state: UniformListState::new(), scroll_state: ScrollStateHandle::default(), @@ -128,7 +192,17 @@ impl ClaudeCodePanelView { } } - /// Whether the `claude` CLI is resolvable on `PATH` right now (PRODUCT §6). + /// The pane configuration (tab title) handed to [`PaneView`] by the wrapper. + pub fn pane_configuration(&self) -> ModelHandle { + self.pane_configuration.clone() + } + + /// Focus the message input (PRODUCT §34: keyboard-first). + pub fn focus(&mut self, ctx: &mut ViewContext) { + ctx.focus(&self.input_editor); + } + + /// Whether the `claude` CLI is resolvable on `PATH` right now (PRODUCT §4). fn claude_available() -> bool { resolve_executable(CLAUDE_BINARY).is_some() } @@ -139,7 +213,7 @@ impl ClaudeCodePanelView { event: &EditorEvent, ctx: &mut ViewContext, ) { - // PRODUCT §43: Enter sends; Shift+Enter is handled by the editor itself + // PRODUCT §15: 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); @@ -158,7 +232,7 @@ impl ClaudeCodePanelView { .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. + // PRODUCT §15: empty / whitespace-only messages are a no-op. return; } self.transcript @@ -171,52 +245,18 @@ impl ClaudeCodePanelView { 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. + // 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 + /// Render the transcript into a [`UniformList`] (PRODUCT §14) wrapped in a /// vertical [`Scrollable`], mirroring [`GlobalSearchView::render_results`]. - /// Bottom-stick auto-scroll (PRODUCT §22) layers on in 7c when content + /// Bottom-stick auto-scroll (PRODUCT §14) 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); @@ -227,7 +267,7 @@ impl ClaudeCodePanelView { let build_items = move |range: Range, app: &AppContext| { let view_handle = handle .upgrade(app) - .expect("ClaudeCodePanelView handle should be upgradeable"); + .expect("ClaudeCodeView handle should be upgradeable"); let view = view_handle.as_ref(app); let appearance = Appearance::as_ref(app); (range.start..range.end) @@ -249,6 +289,8 @@ impl ClaudeCodePanelView { .finish() } + /// The docked composer (PRODUCT §15): the message input plus a send button, + /// pinned to the bottom of the pane Claude-app style. fn render_input(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); let input_view = Container::new(ChildView::new(&self.input_editor).finish()) @@ -259,8 +301,8 @@ impl ClaudeCodePanelView { .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). + // "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 { @@ -272,7 +314,7 @@ impl ClaudeCodePanelView { label.to_owned(), None, Some(Box::new(|ctx| { - ctx.dispatch_typed_action(ClaudeCodePanelAction::Submit); + ctx.dispatch_typed_action(ClaudeCodeViewAction::Submit); })), self.submit_button.clone(), ) @@ -296,8 +338,8 @@ impl ClaudeCodePanelView { } 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. + // PRODUCT §4: replaces the pane body; names the missing binary, gives an + // install hint, no input affordances. let title = appearance .ui_builder() .span(format!( @@ -311,7 +353,7 @@ impl ClaudeCodePanelView { .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." + `claude` is on your PATH, then re-open this pane." .to_owned(), ) .with_soft_wrap() @@ -323,7 +365,7 @@ impl ClaudeCodePanelView { "Check again".to_owned(), None, Some(Box::new(|ctx| { - ctx.dispatch_typed_action(ClaudeCodePanelAction::Refresh); + ctx.dispatch_typed_action(ClaudeCodeViewAction::Refresh); })), self.refresh_button.clone(), ) @@ -344,19 +386,19 @@ impl ClaudeCodePanelView { } } -impl Entity for ClaudeCodePanelView { - type Event = (); +impl Entity for ClaudeCodeView { + type Event = ClaudeCodeViewEvent; } -impl View for ClaudeCodePanelView { +impl View for ClaudeCodeView { fn ui_name() -> &'static str { - "ClaudeCodePanelView" + "ClaudeCodeView" } 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. + // PRODUCT §34: focus the input on entry so typing just works. Focusing a + // child keeps the view itself in the responder chain, so in-pane + // `ClaudeCodeViewAction` dispatches reach `handle_action` below. if focus_ctx.is_self_focused() { ctx.focus(&self.input_editor); } @@ -364,44 +406,103 @@ impl View for ClaudeCodePanelView { 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(); + // PRODUCT §4: the unavailable state replaces the pane body. The pane + // header (title) is rendered separately by `render_header_content`. + let contents = if Self::claude_available() { + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Max) + .with_child(Shrinkable::new(1.0, self.render_body(app)).finish()) + .with_child(self.render_input(appearance)) + .finish() + } else { + self.render_unavailable_state(appearance) + }; - // 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) + // The focus-grab (TECH §The pane): any click on empty pane chrome + // focuses the input, guaranteeing the view is the dispatch target for + // subsequent in-pane actions — what #67 worked around with a forwarder. + EventHandler::new(contents) .on_left_mouse_down(|ctx, _, _| { - ctx.dispatch_typed_action(ClaudeCodePanelAction::FocusInput); + ctx.dispatch_typed_action(ClaudeCodeViewAction::FocusInput); DispatchEventResult::StopPropagation }) .finish() } } -impl TypedActionView for ClaudeCodePanelView { - type Action = ClaudeCodePanelAction; +impl TypedActionView for ClaudeCodeView { + type Action = ClaudeCodeViewAction; - fn handle_action(&mut self, action: &ClaudeCodePanelAction, ctx: &mut ViewContext) { + fn handle_action(&mut self, action: &ClaudeCodeViewAction, 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(), + ClaudeCodeViewAction::Submit => self.submit(ctx), + ClaudeCodeViewAction::FocusInput => ctx.focus(&self.input_editor), + ClaudeCodeViewAction::OpenUrl(url) => ctx.open_url(&url.url), + // PRODUCT §4: render re-checks availability, so a notify suffices. + ClaudeCodeViewAction::Refresh => ctx.notify(), } } } +impl BackingView for ClaudeCodeView { + type PaneHeaderOverflowMenuAction = (); + type CustomAction = (); + type AssociatedData = (); + + fn handle_pane_header_overflow_menu_action( + &mut self, + _action: &(), + _ctx: &mut ViewContext, + ) { + // No overflow menu items in 7b. + } + + fn close(&mut self, ctx: &mut ViewContext) { + // 7c also tears down the live `claude` process here (PRODUCT §8); 7b has + // no driver, so closing the pane just drops the synthetic transcript. + ctx.emit(ClaudeCodeViewEvent::Pane(PaneEvent::Close)); + } + + fn focus_contents(&mut self, ctx: &mut ViewContext) { + self.focus(ctx); + } + + fn render_header_content( + &self, + _ctx: &view::HeaderRenderContext<'_>, + _app: &AppContext, + ) -> HeaderContent { + // PRODUCT §5: title "Claude Code" with the session cwd as a secondary + // line. Net-new chrome (the Agent-block header was service-coupled; + // TECH matrix marks it do-NOT-port). + let cwd = self + .cwd + .as_ref() + .map(|p| p.display().to_string()) + .or_else(|| { + std::env::current_dir() + .ok() + .map(|p| p.display().to_string()) + }); + HeaderContent::Standard(StandardHeader { + title: PANE_TITLE.to_owned(), + title_secondary: cwd, + title_style: None, + title_clip_config: ClipConfig::start(), + title_max_width: None, + left_of_title: None, + right_of_title: None, + left_of_overflow: None, + options: StandardHeaderOptions::default(), + }) + } + + fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle, _ctx: &mut ViewContext) { + self.focus_handle = Some(focus_handle); + } +} + // ---------- transcript rendering (the ported leaf) ---------- /// Bridge dispatch (TECH §The bridge): one arm per [`TranscriptItem`]. 7b @@ -437,7 +538,7 @@ fn render_item(item: &TranscriptItem, appearance: &Appearance) -> Box render_code_block(&code.code, appearance), @@ -566,12 +667,12 @@ fn render_notice(message: &str, appearance: &Appearance) -> Box { ) .with_padding_top(6.) .with_padding_bottom(6.) - .with_padding_left(PANEL_LEFT_MARGIN) + .with_padding_left(TRANSCRIPT_LEFT_MARGIN) .with_padding_right(20.) .finish() } -/// Error surfaced verbatim from `claude` (PRODUCT §55) on a themed surface. The +/// Error surfaced verbatim from `claude` (PRODUCT §30) on a themed surface. The /// copy affordance lands with the live driver in 7c. fn render_error(message: &str, appearance: &Appearance) -> Box { Container::new( @@ -585,7 +686,7 @@ fn render_error(message: &str, appearance: &Appearance) -> Box { .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_left(TRANSCRIPT_LEFT_MARGIN) .with_padding_right(20.) .finish() } @@ -673,9 +774,9 @@ fn synthetic_reply(user_text: &str) -> Vec { ] } -/// 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. +/// Zero state: a short explanation. The single-line message input and "Start +/// session" affordance live in the always-present composer below. The "Resume…" +/// entry point (PRODUCT §36) arrives with the session list in 7h. fn render_zero_state(appearance: &Appearance) -> Box { let explanation = appearance .ui_builder() diff --git a/app/src/launch_configs/launch_config.rs b/app/src/launch_configs/launch_config.rs index 6ec2f7e196..c39def0db2 100644 --- a/app/src/launch_configs/launch_config.rs +++ b/app/src/launch_configs/launch_config.rs @@ -155,6 +155,8 @@ impl TryFrom for PaneTemplateType { | LeafContents::ExecutionProfileEditor | LeafContents::GetStarted | LeafContents::NetworkLog + // twarp 07 (7b): Claude Code panes aren't saved in launch configs. + | LeafContents::ClaudeCode | LeafContents::Welcome { .. } | LeafContents::AIDocument(_) | LeafContents::AmbientAgent(_) => { diff --git a/app/src/lib.rs b/app/src/lib.rs index d91d29d0db..796735b39e 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -49,10 +49,12 @@ 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; +// twarp 07 (7b): the Claude Code main-content pane view. Opened by typing +// `claude` in a terminal (re-spec #70 moved it here from the #69 sidebar). +// Always-on (no feature flag — acceptable on a personal fork; degrades to the +// unavailable state when `claude` is off PATH, PRODUCT §4). See TECH.md +// §Feature flag & rollout. +mod claude_code_view; mod cloud_object; mod code; mod code_review; diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 8e010fa0d4..76118a48d6 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -208,6 +208,7 @@ mod tests; pub use crate::code_review::CodeReviewPanelArg; // twarp: 2c-d — CodeDiffPane / ExecutionProfileEditorPane / AIFactPane removed (AI panes deleted) +pub use pane::claude_code_pane::ClaudeCodePane; // twarp 07 (7b) pub use pane::code_pane::CodePane; pub use pane::env_var_collection_pane::EnvVarCollectionPane; pub use pane::file_pane::FilePane; @@ -546,6 +547,13 @@ pub enum Event { /// The session that the path was opened from. session: Arc, }, + /// twarp 07 (7b): tell the workspace to open a Claude Code pane (PRODUCT §1). + OpenClaudeCodePane { + /// The args after `claude` (`claude ` seeds the first turn). + args: String, + /// The originating terminal's working directory (PRODUCT §4). + cwd: Option, + }, OpenWarpDriveLink { open_warp_drive_args: OpenWarpDriveObjectArgs, }, @@ -1778,6 +1786,14 @@ impl PaneGroup { "Network log pane should not have been persisted, as it cannot be restored" )) } + // twarp 07 (7b): Claude Code panes are not persisted (see + // `LeafContents::is_persisted`) — a restored pane can't replay a + // live `claude` process. `save_pane_state` skips them, so reaching + // this arm is a persistence-side programmer error. Sessions reopen + // via `claude --resume` from the 7h session list. + LeafContents::ClaudeCode => Err(anyhow::anyhow!( + "Claude Code pane should not have been persisted, as it cannot be restored" + )), LeafContents::GetStarted => { if !FeatureFlag::GetStartedTab.is_enabled() { Err(anyhow::anyhow!("GetStarted pane not supported")) diff --git a/app/src/pane_group/pane/claude_code_pane.rs b/app/src/pane_group/pane/claude_code_pane.rs new file mode 100644 index 0000000000..3dfca1c426 --- /dev/null +++ b/app/src/pane_group/pane/claude_code_pane.rs @@ -0,0 +1,138 @@ +//! [`PaneContent`] wrapper that hosts [`ClaudeCodeView`] as a first-class +//! main-content pane (roadmap feature 07, sub-phase 7b). +//! +//! Modeled on [`NetworkLogPane`](super::network_log_pane) — the simplest +//! non-persisted pane: it wraps the view in a [`PaneView`], forwards the view's +//! [`PaneEvent`]s to the [`PaneGroup`], and snapshots to a non-persisted +//! [`LeafContents::ClaudeCode`] (twarp keeps no transcript store; live history +//! comes from `claude --resume`, wired in 7h). Unlike `NetworkLogPane` there is +//! no pane manager: a Claude Code pane is opened on demand by the `claude` +//! terminal trigger and has no global registry. + +use std::path::PathBuf; + +use warpui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle}; + +use crate::app_state::LeafContents; +use crate::claude_code_view::{ClaudeCodeView, ClaudeCodeViewEvent}; + +use super::{ + view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink, + ShareableLinkError, +}; + +pub struct ClaudeCodePane { + view: ViewHandle>, + pane_configuration: ModelHandle, +} + +impl ClaudeCodePane { + pub fn from_view(claude_code_view: ViewHandle, ctx: &mut AppContext) -> Self { + let pane_configuration = claude_code_view.as_ref(ctx).pane_configuration(); + + let view = ctx.add_typed_action_view(claude_code_view.window_id(ctx), |ctx| { + let pane_id = PaneId::from_claude_code_pane_ctx(ctx); + PaneView::new( + pane_id, + claude_code_view, + (), + pane_configuration.clone(), + ctx, + ) + }); + + Self { + view, + pane_configuration, + } + } + + /// Open a fresh Claude Code pane. `initial_prompt` is the `claude ` + /// positional (PRODUCT §2); `cwd` is the originating terminal's directory + /// (PRODUCT §4). + pub fn new( + initial_prompt: Option, + cwd: Option, + ctx: &mut ViewContext, + ) -> Self { + let view = + ctx.add_typed_action_view(move |ctx| ClaudeCodeView::new(initial_prompt, cwd, ctx)); + Self::from_view(view, ctx) + } + + pub fn claude_code_view(&self, ctx: &AppContext) -> ViewHandle { + self.view.as_ref(ctx).child(ctx) + } +} + +impl PaneContent for ClaudeCodePane { + fn id(&self) -> PaneId { + PaneId::from_claude_code_pane_view(&self.view) + } + + fn attach( + &self, + _group: &PaneGroup, + focus_handle: crate::pane_group::focus_state::PaneFocusHandle, + ctx: &mut ViewContext, + ) { + self.view + .update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx)); + + let claude_code_view = self.claude_code_view(ctx); + let pane_id = self.id(); + + ctx.subscribe_to_view(&claude_code_view, move |pane_group, _, event, ctx| { + let ClaudeCodeViewEvent::Pane(pane_event) = event; + pane_group.handle_pane_event(pane_id, pane_event, ctx) + }); + ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| { + group.handle_pane_view_event(pane_id, event, ctx); + }); + } + + fn detach( + &self, + _group: &PaneGroup, + _detach_type: DetachType, + ctx: &mut ViewContext, + ) { + // No manager to deregister from; just drop the subscriptions. Closing + // the pane drops `ClaudeCodeView`, which (in 7c) kills the live + // `claude` process via `kill_on_drop`. + let claude_code_view = self.claude_code_view(ctx); + ctx.unsubscribe_to_view(&claude_code_view); + ctx.unsubscribe_to_view(&self.view); + } + + fn snapshot(&self, _app: &AppContext) -> LeafContents { + // Non-persisted (see `LeafContents::is_persisted`): a restored pane + // can't replay a live `claude` process, and twarp keeps no transcript + // store. Session restore is `claude --resume` from the 7h session list. + LeafContents::ClaudeCode + } + + fn has_application_focus(&self, ctx: &mut ViewContext) -> bool { + self.view.is_self_or_child_focused(ctx) + } + + fn focus(&self, ctx: &mut ViewContext) { + self.claude_code_view(ctx) + .update(ctx, |view, ctx| view.focus(ctx)); + } + + fn shareable_link( + &self, + _ctx: &mut ViewContext, + ) -> Result { + Ok(ShareableLink::Base) + } + + fn pane_configuration(&self) -> ModelHandle { + self.pane_configuration.clone() + } + + fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool { + self.view.as_ref(ctx).is_being_dragged() + } +} diff --git a/app/src/pane_group/pane/mod.rs b/app/src/pane_group/pane/mod.rs index 30a43daabc..1b8ee88668 100644 --- a/app/src/pane_group/pane/mod.rs +++ b/app/src/pane_group/pane/mod.rs @@ -9,6 +9,8 @@ //! The [`PaneId`] must be created via a [`PaneView`]. The [`PaneId`] is consequently //! used to render a [`PaneView`] which internally renders the pane, including the [`BackingView`]. // twarp: 2c-d — code_diff_pane / code_diff_pane_model / execution_profile_editor_pane removed (AI panes) +// twarp 07 (7b): the Claude Code main-content pane host. +pub(super) mod claude_code_pane; pub(super) mod code_pane; pub(super) mod env_var_collection_pane; pub(super) mod file_pane; @@ -31,6 +33,7 @@ use crate::pane_group::pane::get_started_view::GetStartedView; use crate::view_components::action_button::ActionButton; use crate::{ // twarp: 2c-d — ExecutionProfileEditorView/AIDocumentView/CodeDiffView/AIFactView imports removed (AI panes) + claude_code_view::ClaudeCodeView, // twarp 07 (7b) code::view::CodeView, drive::sharing::ShareableObject, env_vars::view::env_var_collection::EnvVarCollectionView, @@ -130,6 +133,8 @@ pub(crate) enum IPaneType { Notebook, File, Code, + /// twarp 07 (7b): the Claude Code main-content pane. + ClaudeCode, CodeDiff, EnvVarCollection, Workflow, @@ -153,6 +158,7 @@ impl Display for IPaneType { IPaneType::Notebook => write!(f, "Notebook"), IPaneType::File => write!(f, "File"), IPaneType::Code => write!(f, "Code"), + IPaneType::ClaudeCode => write!(f, "Claude Code"), IPaneType::CodeDiff => write!(f, "Code Diff"), IPaneType::EnvVarCollection => write!(f, "Environment Variable Collection"), IPaneType::Workflow => write!(f, "Workflow"), @@ -217,6 +223,11 @@ impl PaneId { Self::new_from_ctx(IPaneType::Code, ctx) } + /// twarp 07 (7b): creates a [`PaneId`] from a [`ViewContext>`]. + pub fn from_claude_code_pane_ctx(ctx: &ViewContext>) -> Self { + Self::new_from_ctx(IPaneType::ClaudeCode, ctx) + } + // twarp: 2c-d — from_code_diff_pane_ctx / from_ai_fact_pane_ctx / from_ai_document_pane_ctx / // from_execution_profile_editor_pane_ctx removed (AI panes deleted) @@ -262,6 +273,13 @@ impl PaneId { Self::new(IPaneType::Code, code_pane_view) } + /// twarp 07 (7b): creates a [`PaneId`] from a [`PaneView`] entity ID. + pub fn from_claude_code_pane_view( + claude_code_pane_view: &ViewHandle>, + ) -> Self { + Self::new(IPaneType::ClaudeCode, claude_code_pane_view) + } + // twarp: 2c-d — from_code_diff_pane_view removed (AI pane deleted) /// Creates a [`PaneId`] from a [`PaneView`] entity ID. @@ -385,6 +403,9 @@ impl PaneId { IPaneType::Code => { ChildView::>::with_id(self.0.pane_view_id).finish() } + IPaneType::ClaudeCode => { + ChildView::>::with_id(self.0.pane_view_id).finish() + } IPaneType::CodeDiff => { // twarp: 2c-d — CodeDiffView removed (AI) warpui::elements::Empty::new().finish() diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index d1ef66d032..73b507802d 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -593,6 +593,13 @@ fn handle_terminal_view_event( session: session.clone(), }); } + // twarp 07 (7b): forward the `claude`-trigger event to the workspace. + Event::OpenClaudeCodePane { args, cwd } => { + ctx.emit(pane_group::Event::OpenClaudeCodePane { + args: args.clone(), + cwd: cwd.clone(), + }); + } #[cfg(feature = "local_fs")] Event::PreviewCodeInWarp { source } => { ctx.emit(pane_group::Event::PreviewCodeInWarp { diff --git a/app/src/persistence/sqlite.rs b/app/src/persistence/sqlite.rs index f980d461c2..7d71959358 100644 --- a/app/src/persistence/sqlite.rs +++ b/app/src/persistence/sqlite.rs @@ -1030,7 +1030,8 @@ fn save_pane_state( LeafContents::GetStarted => GET_STARTED_PANE_KIND, LeafContents::Welcome { .. } => WELCOME_PANE_KIND, LeafContents::AIDocument(_) => AI_DOCUMENT_PANE_KIND, - LeafContents::NetworkLog => { + // twarp 07 (7b): ClaudeCode joins NetworkLog as a non-persisted pane. + LeafContents::NetworkLog | LeafContents::ClaudeCode => { // These pane types are filtered out before this function is // called; see `LeafContents::is_persisted` and the skip in // `save_app_state`. Reaching this arm would mean a `pane_nodes` @@ -1266,7 +1267,8 @@ fn save_pane_state( .values(ambient_agent_pane) .execute(conn)?; } - LeafContents::NetworkLog => { + // twarp 07 (7b): ClaudeCode joins NetworkLog as a non-persisted pane. + LeafContents::NetworkLog | LeafContents::ClaudeCode => { // Unreachable: filtered by `is_persisted` in `save_app_state`. } } diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 28c2e0d1ba..3fe0d60e1c 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -2447,6 +2447,14 @@ pub enum Event { layout: external_editor::settings::EditorLayout, }, OpenCodeReviewPane, + /// twarp 07 (7b): a bare top-level `claude [args]` was submitted at the + /// prompt; open a Claude Code pane instead of writing it to the PTY + /// (PRODUCT §1–§3). `args` is everything after `claude`; `cwd` is the + /// terminal's working directory. + OpenClaudeCodePane { + args: String, + cwd: Option, + }, /// Request to attach a diff set as context to the AI conversation AttachDiffSetContext { diff_mode: DiffMode, @@ -7226,6 +7234,53 @@ impl Input { /// 3. There is an active, long-running command. /// /// Returns `true` if the command was executed, `false` otherwise. + /// twarp 07 (7b): conservative classifier for the `claude`-at-submit + /// trigger (PRODUCT §3). Returns `Some((args, cwd))` only when `command` is + /// a single bare top-level `claude` invocation — not piped, `&&`/`;`-chained, + /// inside a subshell, nor a full path — and the `claude` binary resolves on + /// `PATH`. `args` is everything after the program token; `cwd` is the + /// terminal's working directory. When in doubt it returns `None`, so the + /// command runs raw (TECH §The trigger) — never swallow a real shell command. + fn claude_pane_trigger( + &self, + command: &str, + ctx: &ViewContext, + ) -> Option<(String, Option)> { + const CLAUDE_PROGRAM: &str = "claude"; + + // Tokenize with the shell-aware completer parser (precedent: + // terminal/alias.rs, terminal/package_installers.rs) — never hand-roll + // shell parsing. `top_level_command` strips leading env-var assignments + // and returns `None` for pipelines, `&&`/`;` chains, and subshells, so + // only a bare top-level program token reaches the equality check. + let escape_char = self + .editor + .read(ctx, |editor, _| editor.shell_family()) + .unwrap_or(ShellFamily::Posix) + .escape_char(); + let program = warp_completer::parsers::simple::top_level_command(command, escape_char)?; + // A full path (`/usr/bin/claude`) tokenizes to the path, not `claude`, + // so it is intentionally NOT intercepted (PRODUCT §3). + if program != CLAUDE_PROGRAM { + return None; + } + // PRODUCT §4: if `claude` isn't on PATH, don't intercept — let the + // shell report "command not found". + crate::util::path::resolve_executable(CLAUDE_PROGRAM)?; + // Everything after the (lowercase) `claude` program token becomes the + // forwarded args; `claude` alone yields an empty string (PRODUCT §2). + let args = command + .split_once(CLAUDE_PROGRAM) + .map(|(_, rest)| rest.trim().to_owned()) + .unwrap_or_default(); + let cwd = self + .active_block_metadata + .as_ref() + .and_then(|metadata| metadata.current_working_directory()) + .map(PathBuf::from); + Some((args, cwd)) + } + fn try_execute_command_from_source( &mut self, command: &str, @@ -7330,7 +7385,29 @@ impl Input { }); } - self.start_block_and_write_command_to_pty(command, source, ctx); + // twarp 07 (7b): intercept a bare top-level `claude` and open a + // Claude Code pane instead of running the raw CLI in a block + // (PRODUCT §1–§3). Conservative — only a user-typed, on-PATH, bare + // `claude` token matches; anything else falls through and runs raw. + let claude_trigger = matches!(source, CommandExecutionSource::User) + .then(|| self.claude_pane_trigger(command, ctx)) + .flatten(); + if let Some((args, cwd)) = claude_trigger { + ctx.emit(Event::OpenClaudeCodePane { args, cwd }); + // PRODUCT §1: a brief note so the command isn't silently + // swallowed (we write nothing to the PTY, so no block starts). + let window_id = ctx.window_id(); + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::default("Opened Claude Code in a pane.".to_owned()), + window_id, + ctx, + ); + }); + self.clear_buffer_and_reset_undo_stack(ctx); + } else { + self.start_block_and_write_command_to_pty(command, source, ctx); + } did_execute = true; } else { // We don't want to submit the command if precmd has not diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 9e305e4eb0..fc3dfa4d17 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -2963,6 +2963,13 @@ pub enum Event { /// The session that the file belongs to. session: Arc, }, + /// twarp 07 (7b): a bare top-level `claude` was submitted; tell the + /// workspace to open a Claude Code pane (PRODUCT §1). Bubbled from the + /// input's `OpenClaudeCodePane` via `handle_input_event`. + OpenClaudeCodePane { + args: String, + cwd: Option, + }, #[cfg(feature = "local_fs")] OpenCodeInWarp { source: CodeSource, @@ -20002,6 +20009,14 @@ impl TerminalView { layout: *layout, }); } + // twarp 07 (7b): bubble the input's `claude`-trigger event up to the + // pane group / workspace, which opens the Claude Code pane. + InputEvent::OpenClaudeCodePane { args, cwd } => { + ctx.emit(Event::OpenClaudeCodePane { + args: args.clone(), + cwd: cwd.clone(), + }); + } InputEvent::OpenCodeReviewPane => { ctx.emit(Event::OpenCodeReviewPane(CodeReviewPanelArg { repo_path: self.current_repo_path.clone(), diff --git a/app/src/util/bindings.rs b/app/src/util/bindings.rs index 606c2c23da..ea827af3da 100644 --- a/app/src/util/bindings.rs +++ b/app/src/util/bindings.rs @@ -137,8 +137,8 @@ pub enum CustomAction { GoToLine, ToggleGlobalSearch, ToggleConversationListView, - /// twarp 07: toggle the Claude Code left-panel tab (PRODUCT §2). - ToggleClaudeCodePanel, + // twarp 07 (7b): ToggleClaudeCodePanel removed — Claude Code is a + // main-content pane opened by typing `claude` (re-spec #70), no chord. } lazy_static! { @@ -439,13 +439,9 @@ 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 07 (7b): the ⌘⌥K Claude Code toggle was removed — the chat is a + // main-content pane opened by typing `claude` (re-spec #70), not a + // chord-toggled sidebar tab. // 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 0710809ae6..957bd364de 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -519,11 +519,9 @@ 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, + // twarp 07 (7b): ToggleClaudeCodePanel removed — the Claude Code chat is a + // main-content pane opened by typing `claude` (re-spec #70), not a sidebar + // toggle. /// Open the Build Plan Migration Modal (for debugging) #[cfg(debug_assertions)] OpenBuildPlanMigrationModal, @@ -903,7 +901,6 @@ impl WorkspaceAction { | ToggleGlobalSearch | OpenGlobalSearch | ToggleConversationListView - | ToggleClaudeCodePanel | ToggleNotificationMailbox { .. } | OpenLightbox { .. } | UpdateLightboxImage { .. } diff --git a/app/src/workspace/mod.rs b/app/src/workspace/mod.rs index 7c3fa63ab6..0439570d58 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_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, + 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, }; pub use one_time_modal_model::OneTimeModalModel; pub use registry::WorkspaceRegistry; @@ -878,18 +878,8 @@ 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), + // twarp 07 (7b): the ⌘⌥K Claude Code toggle binding was removed — the + // chat is a main-content pane opened by typing `claude` (re-spec #70). 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 3514b56a81..e23d1e1d0e 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -205,8 +205,8 @@ use crate::notebooks::manager::{NotebookManager, NotebookSource}; #[cfg(feature = "local_fs")] use crate::pane_group::FilePane; use crate::pane_group::{ - self, AnyPaneContent, CodePane, Direction, NewTerminalOptions, PaneContent, PanesLayout, - TabBarHoverIndex, + self, AnyPaneContent, ClaudeCodePane, CodePane, Direction, NewTerminalOptions, PaneContent, + PanesLayout, TabBarHoverIndex, }; use crate::remote_server::manager::RemoteServerManager; #[cfg(feature = "local_fs")] @@ -561,8 +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"; +// twarp 07 (7b): the Claude Code left-panel toggle binding was removed — the +// chat is a main-content pane opened by typing `claude` (re-spec #70). const KEYBINDINGS_TO_CACHE: [&str; 3] = [ TOGGLE_RESOURCE_CENTER_KEYBINDING_NAME, @@ -3409,7 +3409,6 @@ 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); @@ -12066,6 +12065,31 @@ impl Workspace { }); } + /// twarp 07 (7b): open a Claude Code pane in the active tab's pane group and + /// focus it — the destination of the `claude` terminal trigger (PRODUCT §1, + /// §5). `args` carries the `claude ` positional (an empty/whitespace + /// `args` opens a bare pane, PRODUCT §2); `cwd` is the originating terminal's + /// directory (PRODUCT §4). Provisional placement: a focused split in the + /// active group, mirroring `open_network_log_pane` (PRODUCT §load-bearing-2; + /// TECH §The pane). + pub(crate) fn open_claude_code_pane( + &mut self, + args: String, + cwd: Option, + ctx: &mut ViewContext, + ) { + let initial_prompt = Some(args).filter(|a| !a.trim().is_empty()); + let pane = ClaudeCodePane::new(initial_prompt, cwd, ctx); + self.active_tab_pane_group().update(ctx, |pane_group, ctx| { + pane_group.add_pane_with_direction( + Direction::Right, + pane, + true, /* focus_new_pane */ + ctx, + ); + }); + } + fn handle_file_tree_event( &mut self, pane_group: ViewHandle, @@ -12242,6 +12266,10 @@ impl Workspace { self.open_file_notebook(path.clone(), Some(session.clone()), layout, ctx); } } + // twarp 07 (7b): the `claude` terminal trigger opens a Claude Code pane. + pane_group::Event::OpenClaudeCodePane { args, cwd } => { + self.open_claude_code_pane(args.clone(), cwd.clone(), ctx); + } pane_group::Event::MoveToSpace { cloud_object_type_and_id, space, @@ -15266,7 +15294,6 @@ impl Workspace { ToolPanelView::GlobalSearch { .. } => "Global search", ToolPanelView::WarpDrive => "Warp Drive", ToolPanelView::Shortcuts => "Custom shortcuts", - ToolPanelView::ClaudeCode => "Claude Code", ToolPanelView::ConversationListView => "Agent conversations", } } else { @@ -15322,7 +15349,6 @@ impl Workspace { ToolPanelView::GlobalSearch { .. } => "Global search", ToolPanelView::WarpDrive => "Warp Drive", ToolPanelView::Shortcuts => "Custom shortcuts", - ToolPanelView::ClaudeCode => "Claude Code", ToolPanelView::ConversationListView => "Agent conversations", } } else { @@ -18108,9 +18134,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); + // twarp 07 (7b): no Claude Code toolbelt tab — the chat is a + // main-content pane opened by typing `claude` (re-spec #70). A read-only + // session-list entry returns in 7h. if WarpDriveSettings::is_warp_drive_enabled(ctx) { views.push(ToolPanelView::WarpDrive); } @@ -19753,20 +19779,8 @@ 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 07 (7b): ToggleClaudeCodePanel handler removed — the chat is + // a main-content pane opened by typing `claude` (re-spec #70). // 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 045c59ecb1..7be16902be 100644 --- a/app/src/workspace/view/left_panel.rs +++ b/app/src/workspace/view/left_panel.rs @@ -20,7 +20,8 @@ use warpui::{ }; // twarp: 2c-d — AgentConversationsModel/AIConversationId stubs no longer needed in this file. -use crate::claude_code_panel::ClaudeCodePanelView; +// twarp 07 (7b): the Claude Code chat moved to a main-content pane (re-spec #70); +// this left panel no longer hosts it. #[cfg(feature = "local_fs")] use crate::code::file_tree::FileTreeEvent; use crate::coding_panel_enablement_state::CodingPanelEnablementState; @@ -41,10 +42,9 @@ use crate::workspace::view::global_search::view::{ Event as GlobalSearchViewEvent, GlobalSearchEntryFocus, GlobalSearchView, }; use crate::workspace::view::{ - 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, + 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, @@ -70,8 +70,6 @@ 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 } @@ -161,8 +159,6 @@ 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, } @@ -206,10 +202,6 @@ 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, } @@ -271,8 +263,6 @@ 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, @@ -774,11 +764,6 @@ 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); @@ -855,7 +840,6 @@ 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, @@ -991,20 +975,6 @@ 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, @@ -1112,10 +1082,6 @@ 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, @@ -1280,9 +1246,6 @@ 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 => {} } @@ -2365,7 +2328,6 @@ 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(_) @@ -3102,16 +3064,6 @@ 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; @@ -3666,8 +3618,6 @@ 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 => {} } @@ -3682,9 +3632,6 @@ 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 ]; @@ -3758,16 +3705,6 @@ 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/app/src/workspace/view/vertical_tabs.rs b/app/src/workspace/view/vertical_tabs.rs index 2eab5130b4..644be7dd8d 100644 --- a/app/src/workspace/view/vertical_tabs.rs +++ b/app/src/workspace/view/vertical_tabs.rs @@ -3097,6 +3097,9 @@ impl PaneGroup { IPaneType::ExecutionProfileEditor => TypedPane::ExecutionProfileEditor, IPaneType::GetStarted | IPaneType::NetworkLog + // twarp 07 (7b): the Claude Code pane has no vertical-tab-specific + // row content; treat it like the other simple panes. + | IPaneType::ClaudeCode | IPaneType::Welcome | IPaneType::DeferredPlaceholder => TypedPane::Other, #[cfg(test)] From f8be3377b6c6c557105da823a613856adb30f06e Mon Sep 17 00:00:00 2001 From: Timomak Date: Tue, 2 Jun 2026 15:52:22 +0200 Subject: [PATCH 2/3] [twarp 07b] roadmap: advance 07 to impl-in-review; tick 7b (PR #71); mark #70 merged Co-Authored-By: Claude Opus 4.8 (1M context) --- roadmap/07-claude-code-panel/STATUS.md | 8 ++++---- roadmap/ROADMAP.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/roadmap/07-claude-code-panel/STATUS.md b/roadmap/07-claude-code-panel/STATUS.md index 8d2dc13bc4..47ccf8e8f3 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 [#70](https://github.com/timomak/twarp/pull/70) open — main-pane redirect) -**Spec PRs:** [#66](https://github.com/timomak/twarp/pull/66) (merged) · port-plan re-spec [#68](https://github.com/timomak/twarp/pull/68) (merged) · **main-pane re-spec [#70](https://github.com/timomak/twarp/pull/70) (open)** -**Impl PRs:** [#67](https://github.com/timomak/twarp/pull/67) — abandoned (rebuilt from primitives); **owner to close.** · [#69](https://github.com/timomak/twarp/pull/69) — 7b sidebar build, **merged**: landed the placement-agnostic core (`crates/claude_code` + the ported renderer) plus a now-obsolete sidebar host. Rendering was correct; sidebar placement superseded by #70 — the re-scoped 7b **relocates** the host to a main-content pane. +**Phase:** impl-in-review (7b PR [#71](https://github.com/timomak/twarp/pull/71) open — main-pane host + `claude` trigger + relocated renderer) +**Spec PRs:** [#66](https://github.com/timomak/twarp/pull/66) (merged) · port-plan re-spec [#68](https://github.com/timomak/twarp/pull/68) (merged) · main-pane re-spec [#70](https://github.com/timomak/twarp/pull/70) (merged) +**Impl PRs:** [#67](https://github.com/timomak/twarp/pull/67) — abandoned (rebuilt from primitives); **owner to close.** · [#69](https://github.com/timomak/twarp/pull/69) — 7b sidebar build, **merged**: landed the placement-agnostic core (`crates/claude_code` + the ported renderer) plus a now-obsolete sidebar host. Rendering was correct; sidebar placement superseded by #70. · **[#71](https://github.com/timomak/twarp/pull/71) — 7b main-pane host (open):** relocated the renderer into `IPaneType::ClaudeCode` (`ClaudeCodeView`/`ClaudeCodePane`), added the `claude`-at-submit terminal trigger, deleted the sidebar host. `cargo check`/`clippy`/`fmt` clean; `claude_code` 19/19. ## Scope @@ -25,7 +25,7 @@ Full behavior in [PRODUCT.md](PRODUCT.md); implementation plan in [TECH.md](TECH 7a stays done (audit + specs, amended by both re-specs). 7b–7h are re-scoped to land in the pane (TECH.md §Re-derived sub-phase plan). - [x] **7a — Audit + specs.** Renderer detangling gate resolved (per-leaf port-and-adapt from `fea2f7ea`); main-pane host + terminal trigger audited (`terminal/input.rs` submit hook; `pane_group` `IPaneType`/`CodePane` model). (Specs [#66], [#68], [#70].) -- [ ] **7b — Pane host + `claude`-at-submit trigger + ported transcript (stub session).** `crates/claude_code` + the ported renderer are **already in master (via #69)** — 7b **relocates** them, it does not re-introduce them. Add `IPaneType::ClaudeCode` + `ClaudeCodePane`/`ClaudeCodeView` (modeled on `CodePane`) and move the merged transcript renderer into the pane. Intercept a top-level `claude` in `terminal/input.rs` → open the pane. Render a synthetic transcript with a docked composer; **delete the #69 sidebar host** (`left_panel` wiring + ⌘⌥K binding). **Acceptance: `claude` opens a real main-content pane whose sample transcript renders as themed Markdown in Claude-app shape, and the sidebar no longer hosts the chat.** PRODUCT §1–§7, §12–§15, §32–§33. +- [x] **7b — Pane host + `claude`-at-submit trigger + ported transcript (stub session).** *(PR [#71](https://github.com/timomak/twarp/pull/71), in review.)* `crates/claude_code` + the ported renderer are **already in master (via #69)** — 7b **relocates** them, it does not re-introduce them. Add `IPaneType::ClaudeCode` + `ClaudeCodePane`/`ClaudeCodeView` (modeled on `CodePane`) and move the merged transcript renderer into the pane. Intercept a top-level `claude` in `terminal/input.rs` → open the pane. Render a synthetic transcript with a docked composer; **delete the #69 sidebar host** (`left_panel` wiring + ⌘⌥K binding). **Acceptance: `claude` opens a real main-content pane whose sample transcript renders as themed Markdown in Claude-app shape, and the sidebar no longer hosts the chat.** PRODUCT §1–§7, §12–§15, §32–§33. - [ ] **7c — Live driver in the pane.** Wire `claude_code::driver` → pane via the bridge + `apply_event` pump; forward the `claude ` first turn; streaming/Stop/lifecycle/teardown. PRODUCT §6–§14, §28–§31. - [ ] **7d — Tool cards.** Port `inline_action` chrome; bridge `Tool` → cards + generic fallback for unmapped/`mcp__*`. PRODUCT §16–§19. - [ ] **7e — Diff cards.** Synthesize unified diff → render read-only via feature 05 / `InlineDiffView`. PRODUCT §20–§21. diff --git a/roadmap/ROADMAP.md b/roadmap/ROADMAP.md index 7a28c75f7e..15b609a7ca 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), main-pane respec [#70](https://github.com/timomak/twarp/pull/70) | 7b [#69](https://github.com/timomak/twarp/pull/69) (merged — sidebar host; relocating to main pane per #70) | +| 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), main-pane respec [#70](https://github.com/timomak/twarp/pull/70) | 7b [#69](https://github.com/timomak/twarp/pull/69) (merged — sidebar host, superseded), 7b [#71](https://github.com/timomak/twarp/pull/71) (main-pane host) | | 08 | [Rebrand to twarp](08-rebrand/STATUS.md) | not-started | — | — | | 09 | [File editor with go-to-definition](09-file-editor/STATUS.md) | not-started | — | — | | 10 | [Git blame](10-git-blame/STATUS.md) | not-started | — | — | From ba6957326f51f8f1a0a93695b249490379c0d199 Mon Sep 17 00:00:00 2001 From: Timomak Date: Thu, 4 Jun 2026 19:56:27 +0200 Subject: [PATCH 3/3] [twarp 07b] fix claude trigger under shell aliases + transcript truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes found testing #71 on a real build: 1. Alias-aware trigger. Warp expands shell aliases at submit, so a user's `alias claude='command claude --flags'` reached the interceptor already expanded as `command claude …`; `top_level_command` then read `command` (the builtin) and the trigger never fired. `claude_pane_trigger` now peels leading env-var assignments and the `command`/`builtin`/`exec` run-a-program wrappers to recover the real program token, and forwards only a leading positional as the first turn (alias-injected flags are not a prompt). 2. Transcript no longer truncates. The ported `UniformList` clips every row to one measured height, so multi-paragraph assistant replies rendered as a single line. Render transcript items at their natural height in a `ClippedScrollable` column instead (a variable-height virtualized list can return later if very large sessions need it, PRODUCT §14). cargo check/clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/claude_code_view.rs | 56 +++++++++++++++---------------------- app/src/terminal/input.rs | 50 ++++++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 47 deletions(-) diff --git a/app/src/claude_code_view.rs b/app/src/claude_code_view.rs index 09170eab97..4e1d6592c1 100644 --- a/app/src/claude_code_view.rs +++ b/app/src/claude_code_view.rs @@ -33,7 +33,6 @@ //! view in the responder chain. There is **no** `WorkspaceAction` forwarder — //! that was the #67 symptom-fix and is deleted. -use std::ops::Range; use std::path::PathBuf; use claude_code::{Transcript, TranscriptEvent, TranscriptItem}; @@ -41,17 +40,16 @@ 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, + Border, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, + CornerRadius, CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Fill, Flex, + FormattedTextElement, HighlightedHyperlink, HyperlinkUrl, Icon, MainAxisSize, + MouseStateHandle, ParentElement, Radius, ScrollbarWidth, Shrinkable, }, presenter::ChildView, text_layout::ClipConfig, ui_components::components::{UiComponent, UiComponentStyles}, AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View, - ViewContext, ViewHandle, WeakViewHandle, + ViewContext, ViewHandle, }; use crate::appearance::Appearance; @@ -122,13 +120,7 @@ pub struct ClaudeCodeView { /// The working directory of the terminal that opened the pane (PRODUCT §4), /// shown in the header. 7c spawns `claude` here. cwd: Option, - /// 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 §14 — large sessions stay - /// responsive because only visible rows render). - uniform_list_state: UniformListState, - scroll_state: ScrollStateHandle, + scroll_state: ClippedScrollStateHandle, /// Stable mouse-state handles kept across renders so a click's /// mousedown/mouseup hit the same handle. submit_button: MouseStateHandle, @@ -184,9 +176,7 @@ impl ClaudeCodeView { pane_configuration, focus_handle: None, cwd, - handle: ctx.handle(), - uniform_list_state: UniformListState::new(), - scroll_state: ScrollStateHandle::default(), + scroll_state: ClippedScrollStateHandle::default(), submit_button: MouseStateHandle::default(), refresh_button: MouseStateHandle::default(), } @@ -261,25 +251,23 @@ impl ClaudeCodeView { 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("ClaudeCodeView 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( + // A chat transcript has variable-height items (a one-line user turn vs a + // multi-paragraph assistant reply), so a `UniformList` — which clips + // every row to a single measured height — truncated multi-line replies + // to one line. Render each item at its natural height in a column; + // a variable-height virtualized list can return if very large sessions + // need it (PRODUCT §14), but correctness comes first. + let mut column = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_main_axis_size(MainAxisSize::Min); + for item in self.transcript.items() { + column.add_child(render_item(item, appearance)); + } + + ClippedScrollable::vertical( self.scroll_state.clone(), - list.finish_scrollable(), + column.finish(), ScrollbarWidth::Auto, theme.nonactive_ui_detail().into(), theme.active_ui_detail().into(), diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 3fe0d60e1c..00bd5e1ee1 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -7247,32 +7247,56 @@ impl Input { ctx: &ViewContext, ) -> Option<(String, Option)> { const CLAUDE_PROGRAM: &str = "claude"; + // Shell "run-a-program" wrappers a user's alias may inject. Warp expands + // aliases at submit, so the common `alias claude='command claude --flags'` + // pattern (which adds default flags without recursing) reaches us already + // expanded as `command claude …`. Peel these — plus leading env-var + // assignments — to recover the real program token, or the program reads + // as `command` and the trigger never fires. + const PROGRAM_WRAPPERS: &[&str] = &["command", "builtin", "exec"]; // Tokenize with the shell-aware completer parser (precedent: // terminal/alias.rs, terminal/package_installers.rs) — never hand-roll - // shell parsing. `top_level_command` strips leading env-var assignments - // and returns `None` for pipelines, `&&`/`;` chains, and subshells, so - // only a bare top-level program token reaches the equality check. + // shell parsing. `top_level_command` returns `None` for pipelines, + // `&&`/`;` chains, and subshells, keeping detection conservative + // (PRODUCT §3). let escape_char = self .editor .read(ctx, |editor, _| editor.shell_family()) .unwrap_or(ShellFamily::Posix) .escape_char(); - let program = warp_completer::parsers::simple::top_level_command(command, escape_char)?; - // A full path (`/usr/bin/claude`) tokenizes to the path, not `claude`, - // so it is intentionally NOT intercepted (PRODUCT §3). + warp_completer::parsers::simple::top_level_command(command, escape_char)?; + + // Recover the effective program from the first command's tokens, + // skipping leading env-var assignments (`VAR=value`) and the wrappers + // above. A full path (`/usr/bin/claude`) keeps its separators and so + // never matches the bare `claude` token (PRODUCT §3). + let first = + warp_completer::parsers::simple::all_parsed_commands(command, escape_char).next()?; + let mut tokens = first.parts.iter().map(|part| part.as_str()); + let program = loop { + let token = tokens.next()?; + if token.contains('=') || PROGRAM_WRAPPERS.contains(&token) { + continue; + } + break token; + }; if program != CLAUDE_PROGRAM { return None; } // PRODUCT §4: if `claude` isn't on PATH, don't intercept — let the - // shell report "command not found". + // shell's own "command not found" stand. crate::util::path::resolve_executable(CLAUDE_PROGRAM)?; - // Everything after the (lowercase) `claude` program token becomes the - // forwarded args; `claude` alone yields an empty string (PRODUCT §2). - let args = command - .split_once(CLAUDE_PROGRAM) - .map(|(_, rest)| rest.trim().to_owned()) - .unwrap_or_default(); + + // Forward a leading positional prompt as the first turn (PRODUCT §2); + // flags (incl. the alias-injected ones) are not a prompt — proper + // flag → SpawnOptions mapping lands in 7c. + let rest: Vec<&str> = tokens.collect(); + let args = if rest.first().is_some_and(|token| token.starts_with('-')) { + String::new() + } else { + rest.join(" ") + }; let cwd = self .active_block_metadata .as_ref()