From a49e975f91dfb8253c0f54e4f79128d5121e19cf Mon Sep 17 00:00:00 2001 From: JDonaghy Date: Wed, 2 Sep 2026 01:04:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(#730):=20paint=20the=20AI=20panel=20on=20G?= =?UTF-8?q?TK=20=E2=80=94=20the=20last=20of=20#592's=2014=20ScreenLayout?= =?UTF-8?q?=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render_content`'s sidebar match and `try_route_sidebar_mouse_event` both had a `PANEL_AI` holdout since #670 scoped only the other four panel surfaces (quickfix/bottom_tabs/debug_toolbar/panel_hover) and deferred AI to this follow-up. Selecting the AI panel on GTK painted nothing. - Add `render::draw_ai_sidebar_panel` — the paint logic lifted out of TUI's `render_ai_sidebar` and generalized with the `unit_w`/`unit_h` convention `tab_hover_tooltip_paint` (#671) established: `1.0, 1.0` for TUI's cell-native space, `char_width, line_height` in pixels for GTK. One implementation instead of two — TUI's `render_ai_sidebar` now just converts its `Rect` and delegates. - `render_content` gains a real `PANEL_AI` arm painting through this builder and caching the returned `AiSidebarBands` (header/messages/input) in a new `cached_ai_bands` field. - `try_route_sidebar_mouse_event` gains `route_ai_sidebar_event`, mirroring `route_sc_sidebar_event`'s git-commit-box pattern: a press anywhere in the panel focuses it, a press in the input band also activates text entry — resolved against the cached bands, never re-derived (#544/#582/#646). - `GtkDriver` black-box tests in `src/gtk/testing.rs` assert on rendered output (`screen_contains("AI ASSISTANT")`) and on click routing, not on state alone. Both verified RED against unfixed develop (git-stashed the render.rs/gtk/mod.rs/tui_main/panels.rs hunks, confirmed both new tests fail, restored). cargo build && cargo test (targeted) && cargo clippy -- -D warnings && cargo fmt --check all pass. Co-Authored-By: Claude Sonnet 5 --- src/gtk/mod.rs | 85 +++++++++++-- src/gtk/testing.rs | 65 ++++++++++ src/render.rs | 282 +++++++++++++++++++++++++++++++++++++++++ src/tui_main/panels.rs | 190 ++------------------------- 4 files changed, 435 insertions(+), 187 deletions(-) diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs index 1414c4a9..74f749b7 100644 --- a/src/gtk/mod.rs +++ b/src/gtk/mod.rs @@ -726,6 +726,12 @@ struct App { /// `engine.dap_sidebar_action_hits` are relative to this rect's origin, so /// the router needs it to translate an absolute press (#544). cached_dap_action_rect: Cell>, + /// AI-sidebar band geometry (header / message history / input) as the + /// last `render_content` pass painted it, from + /// `render::draw_ai_sidebar_panel`. `route_ai_sidebar_event` resolves + /// presses against this so the click and paint derivations cannot drift + /// (#544/#730). + cached_ai_bands: Cell>, /// Per-group tab-drop geometry (absolute pixel bounds) computed each frame in /// `render_content`. Both the drag overlay (same frame) and the drag hit-test /// in `handle_mouse_drag_msg` (next mouse-move) read this, so the drop-zone @@ -1712,6 +1718,7 @@ impl App { sidebar_pointer_captured: Cell::new(false), cached_sc_bands: Cell::new(None), cached_dap_action_rect: Cell::new(None), + cached_ai_bands: Cell::new(None), cached_tab_bar_zones: Rc::new(RefCell::new(HashMap::new())), cached_drop_groups: Rc::new(RefCell::new(Vec::new())), cached_drop_tbh: Rc::new(Cell::new(0.0)), @@ -7407,9 +7414,10 @@ impl App { engine.handle_ext_sidebar_ui_event(event.clone()); true } - // PANEL_AI and unknowns are not painted through `render_content` - // yet (same `_ =>` holdout that arm has), so there is nothing for a - // click to hit — let it fall through rather than swallow it. + PANEL_AI => self.route_ai_sidebar_event(event), + // Unknown panel id: nothing was painted, so there is nothing + // for a click to hit — let it fall through rather than + // swallow it. _ => false, }; @@ -7543,6 +7551,50 @@ impl App { true } + /// Sidebar routing for the AI panel (#544/#730). + /// + /// `render_content` caches the header/messages/input bands in + /// `cached_ai_bands` at paint time (`render::draw_ai_sidebar_panel`'s + /// return value) — resolving a press against that means the click + /// router can never derive a different layout than the one actually on + /// screen (#544/#582/#646). Neither TUI nor the pre-#730 GTK arm had any + /// body-click routing here (AI-panel focus/edit was keyboard-only on + /// both backends); this adds the same "click focuses the panel, + /// click-in-input activates editing" policy the git sidebar's commit + /// box already uses (`route_sc_sidebar_event` above), consuming the + /// press unconditionally like every other panel arm in + /// `try_route_sidebar_mouse_event` — a click on empty panel padding + /// still belongs to this panel, not the editor underneath it. + fn route_ai_sidebar_event(&mut self, event: &quadraui::UiEvent) -> bool { + let Some(bands) = self.cached_ai_bands.get() else { + return false; + }; + let starts_interaction = matches!( + event, + quadraui::UiEvent::MouseDown { .. } | quadraui::UiEvent::DoubleClick { .. } + ); + let pos = match event { + quadraui::UiEvent::MouseDown { position, .. } + | quadraui::UiEvent::DoubleClick { position, .. } + | quadraui::UiEvent::MouseUp { position, .. } + | quadraui::UiEvent::MouseMoved { position, .. } + | quadraui::UiEvent::Scroll { position, .. } => *position, + _ => return false, + }; + if starts_interaction { + let mut engine = self.engine.borrow_mut(); + engine.ai_has_focus = true; + let in_input = pos.y >= bands.input.y && pos.y < bands.input.y + bands.input.height; + if in_input { + engine.ai_input_active = true; + engine.ai_input_cursor = engine.ai_input.chars().count(); + } else { + engine.ai_input_active = false; + } + } + true + } + fn handle_explorer_da_key(&mut self, key_name: String, unicode: Option, ctrl: bool) { // #426: when an explorer ctx menu is open, dispatch j/k/Esc/Enter // to the engine ctx menu handler. On Enter, forward the returned @@ -9446,12 +9498,29 @@ impl quadraui::ShellApp for App { engine.ext_sidebar_body_rect.set(q_sb); engine.ext_sidebar_system.borrow().render(backend, q_sb); } + PANEL_AI => { + // #730: the last of #592's 14 `ScreenLayout` fields, + // and the straggler #670 deferred. Paints through the + // same `render::draw_ai_sidebar_panel` builder TUI's + // `render_ai_sidebar` calls — `unit_w`/`unit_h` here are + // the pixel `char_width`/`line_height` GTK's other + // row-based panels (PANEL_GIT, PANEL_DEBUG) already + // convert through, unlike TUI's cell-native `1.0, 1.0`. + // Bands are cached for `route_ai_sidebar_event` so the + // click and paint derivations cannot drift (#544). + if let Some(ref ai) = screen.ai_panel { + let bands = render::draw_ai_sidebar_panel( + backend, q_sb, ai, &theme, cw as f32, lh as f32, + ); + self.cached_ai_bands.set(Some(bands)); + } else { + self.cached_ai_bands.set(None); + } + } _ => { - // PANEL_AI and unknowns: not yet migrated to Backend - // primitives (#670 scoped only the other four panel - // surfaces — quickfix/bottom_tabs/debug_toolbar/ - // panel_hover — and left AI for a follow-up issue; see - // that issue's own "no PANEL_AI arm at all" note). + // Unknown panel id: nothing painted, nothing to route a + // click to. + self.cached_ai_bands.set(None); } } diff --git a/src/gtk/testing.rs b/src/gtk/testing.rs index 343ddc69..7e5ca800 100644 --- a/src/gtk/testing.rs +++ b/src/gtk/testing.rs @@ -2462,6 +2462,71 @@ second line here release lands on top of the editor pane (#544)" ); } + + /// AI: selecting the panel must paint its content — the 14th and last of + /// #592's `ScreenLayout` fields, and the straggler #670 deferred (#730). + /// + /// Asserts on the *rendered* header text via `screen_contains`, never on + /// `ai_panel`/`ai_has_focus` state alone — `ScreenLayout.picker` sat + /// populated on GTK for months while nothing painted it (CLAUDE.md + /// rule 1 / #587). + #[test] + fn ai_panel_paints_its_header() { + let h = panel_harness(PANEL_AI); + assert!( + h.driver.screen_contains("AI ASSISTANT"), + "selecting the AI panel must paint its header (#730)" + ); + } + + /// AI: a press in the message-history area must focus the panel without + /// opening the input box, and a press in the input box's own band must + /// also activate text entry — the same "click focuses, click-in-input + /// edits" split `git_panel_click_activates_the_commit_box_but_not_the_ + /// header` exercises for the git sidebar's commit box (#544/#730). + /// + /// The input band is always the bottom-most one `render::draw_ai_ + /// sidebar_panel` paints (header, then message history, then separator + + /// input), so a point near the very bottom edge is reliably inside it + /// without re-deriving the exact row math here. + #[test] + fn ai_panel_click_focuses_panel_and_activates_input_box() { + let mut h = panel_harness(PANEL_AI); + // Give the input box enough text to wrap across several rows, so its + // band grows well past the ~1-row margin `window_edge` (checked + // before sidebar routing, for CSD edge-resize) reserves along the + // window's outer bottom edge. Without this, a click low enough to + // land in the (1-row-tall, empty-input) input band also lands in + // that resize margin and never reaches sidebar routing at all. + h.engine.borrow_mut().ai_input = "x ".repeat(120); + assert!(!h.engine.borrow().ai_has_focus); + assert!(!h.engine.borrow().ai_input_active); + + let sb = h.painted_sidebar_bounds.get().unwrap(); + let lh = h.painted_line_height.get().unwrap() as f32; + + // A press a few rows down (comfortably below the 1-row header, still + // well above the input box) must focus the panel but leave the input + // box inactive. + h.driver.click(sb.x + 20.0, sb.y + lh * 3.0); + assert!( + h.engine.borrow().ai_has_focus, + "a click in the panel body must focus it (#544)" + ); + assert!( + !h.engine.borrow().ai_input_active, + "a click in the message-history area must not activate the input box" + ); + + // A press well inside the (now multi-row) input box's band — but + // clear of the window's bottom-edge resize margin — must also + // activate text entry. + h.driver.click(sb.x + 20.0, sb.y + sb.height - lh * 2.5); + assert!( + h.engine.borrow().ai_input_active, + "a click in the input box must activate it (#544)" + ); + } } /// #669: the five editor-anchored popups (completion, LSP hover, editor diff --git a/src/render.rs b/src/render.rs index b6acbe35..2e4b283c 100644 --- a/src/render.rs +++ b/src/render.rs @@ -2363,6 +2363,22 @@ pub struct AiPanelData { pub input_cursor: usize, } +/// Row zones painted by [`draw_ai_sidebar_panel`], in the caller's own +/// coordinate space (character cells for TUI, pixels for GTK) — mirrors +/// [`ScSidebarBands`], the git-panel equivalent this pattern is copied +/// from. Cache the returned value and reuse it for click routing (#544): +/// re-deriving the layout in the click handler is the bug #544/#582/#646 +/// all trace back to. +#[derive(Debug, Clone, Copy)] +pub struct AiSidebarBands { + /// Header row ("AI ASSISTANT" title). + pub header: quadraui::Rect, + /// Scrollable message-history area. + pub messages: quadraui::Rect, + /// Separator row + input box (everything below the message history). + pub input: quadraui::Rect, +} + // ─── SettingDef ─────────────────────────────────────────────────────────────── // SettingType, SettingDef, and SETTING_DEFS are defined in settings.rs and @@ -10527,6 +10543,272 @@ fn build_ai_panel_data(engine: &Engine) -> Option { }) } +/// One full-row `StatusBar` used as a plain text row — the same +/// "single-segment `StatusBar` stands in for a plain text row" trick +/// [`tab_hover_tooltip_paint`] and TUI's retired `draw_rule_row_q` use +/// (#609/#671). `draw_status_bar`'s own per-backend impl already fills the +/// row's background across the *entire* rect regardless of text length, so +/// unlike that retired TUI-only helper there is no manual space-padding of +/// `text` to `rect`'s width. +fn draw_ai_text_row( + backend: &mut dyn quadraui::Backend, + rect: quadraui::Rect, + text: &str, + fg: quadraui::Color, + bg: quadraui::Color, +) { + if rect.width <= 0.0 || rect.height <= 0.0 { + return; + } + let bar = quadraui::StatusBar { + id: quadraui::WidgetId::new("ai:row"), + left_segments: vec![quadraui::StatusBarSegment { + text: text.to_string(), + fg, + bg, + bold: false, + action_id: None, + }], + right_segments: vec![], + }; + let _ = backend.draw_status_bar(rect, &bar, None, None); +} + +/// Paint the AI assistant sidebar panel through `Backend` primitives — +/// header row, scrollable message history (`Backend::draw_message_list`), +/// separator, and a multi-line growing input box with an inline cursor +/// cell. One implementation shared by TUI's `render_ai_sidebar` and GTK's +/// `render_content` `PANEL_AI` arm (#730 — the 14th and last `ScreenLayout` +/// field #592 tracked, and the straggler #670 deferred). +/// +/// `unit_w` / `unit_h` scale a character cell / text row into the caller's +/// native coordinate space: `1.0, 1.0` for TUI (cell-native — `area` is +/// already in character cells), or `char_width, line_height` in pixels for +/// GTK — the same convention [`tab_hover_tooltip_paint`] and +/// [`sc_sidebar_bands`] use. +/// +/// Returns the [`AiSidebarBands`] this frame painted — cache it and reuse +/// it for click routing (#544/#582/#646: never re-derive the layout in the +/// click handler). +pub fn draw_ai_sidebar_panel( + backend: &mut dyn quadraui::Backend, + area: quadraui::Rect, + ai: &AiPanelData, + theme: &Theme, + unit_w: f32, + unit_h: f32, +) -> AiSidebarBands { + let empty_bands = AiSidebarBands { + header: quadraui::Rect::new(area.x, area.y, 0.0, 0.0), + messages: quadraui::Rect::new(area.x, area.y, 0.0, 0.0), + input: quadraui::Rect::new(area.x, area.y, 0.0, 0.0), + }; + if area.width <= 0.0 || area.height <= 0.0 || unit_w <= 0.0 || unit_h <= 0.0 { + return empty_bands; + } + + backend.set_theme(to_quadraui_theme(theme)); + + let header_fg = to_quadraui_color(theme.status_fg); + let header_bg = to_quadraui_color(theme.status_bg); + let default_fg = to_quadraui_color(theme.foreground); + let dim_fg = to_quadraui_color(theme.line_number_fg); + let panel_bg = to_quadraui_color(theme.completion_bg); + let input_active_bg = to_quadraui_color(theme.fuzzy_selected_bg); + + let area_rows = (area.height / unit_h).floor().max(0.0) as usize; + let area_cols = (area.width / unit_w).floor().max(0.0) as usize; + let bottom = area.y + area.height; + let mut y = area.y; + + // ── Row 0: header ───────────────────────────────────────────────────────── + let header_rect = quadraui::Rect::new(area.x, y, area.width, unit_h); + if y < bottom { + let hdr = if ai.streaming { + " \u{f0e5} AI ASSISTANT (thinking…)" + } else { + " \u{f0e5} AI ASSISTANT" + }; + draw_ai_text_row(backend, header_rect, hdr, header_fg, header_bg); + y += unit_h; + } + + // ── Compute input height (grows with content) ───────────────────────────── + let pfx_len = 3usize; // " > " / " " + let content_w = area_cols.saturating_sub(pfx_len).max(1); + let input_chars: Vec = ai.input.chars().collect(); + let input_line_count = { + let raw = if input_chars.is_empty() { + 1 + } else { + input_chars.len().div_ceil(content_w) + }; + // cap so messages keep at least 3 rows + raw.min(area_rows.saturating_sub(5).max(1)) + }; + // +1 for separator row + let input_rows = input_line_count + 1; + let msg_area_rows = area_rows.saturating_sub(1 + input_rows); + + // ── Message history ─────────────────────────────────────────────────────── + let scroll = ai.scroll_top; + let wrap_w = content_w.saturating_sub(1).max(10); // slightly narrower for " " indent + let q_user_fg = to_quadraui_color(theme.keyword); + let q_asst_fg = to_quadraui_color(theme.string_lit); + let q_default_fg = to_quadraui_color(theme.foreground); + let q_panel_bg = to_quadraui_color(theme.completion_bg); + let mut rows: Vec = Vec::new(); + for msg in &ai.messages { + let is_user = msg.role == "user"; + let role_label = if is_user { "You:" } else { "AI:" }; + let role_fg = if is_user { q_user_fg } else { q_asst_fg }; + rows.push(quadraui::MessageRow::new(role_label, role_fg, 0.0)); + for line in msg.content.lines() { + if line.is_empty() { + rows.push(quadraui::MessageRow::new("", q_default_fg, 2.0)); + continue; + } + let chars: Vec = line.chars().collect(); + let mut pos = 0; + while pos < chars.len() { + let end = (pos + wrap_w).min(chars.len()); + let chunk: String = chars[pos..end].iter().collect(); + rows.push(quadraui::MessageRow::new(chunk, q_default_fg, 2.0)); + pos = end; + } + } + rows.push(quadraui::MessageRow::new("", q_panel_bg, 0.0)); // blank separator + } + + let total = rows.len(); + let start = scroll.min(total.saturating_sub(msg_area_rows)); + let msg_list = quadraui::MessageList { + id: quadraui::WidgetId::new("ai:messages"), + rows, + scroll_top: start, + }; + let msg_area_top = y; + let messages_rect = quadraui::Rect::new( + area.x, + msg_area_top, + area.width, + msg_area_rows as f32 * unit_h, + ); + backend.draw_message_list(messages_rect, &msg_list); + y += msg_area_rows as f32 * unit_h; + + // Fill any rows the message list didn't cover (when there are + // fewer messages than the visible area). + let painted = msg_list.rows.len().saturating_sub(start).min(msg_area_rows); + let mut fill_y = msg_area_top + painted as f32 * unit_h; + let msg_bottom = msg_area_top + msg_area_rows as f32 * unit_h; + while fill_y < msg_bottom { + draw_ai_text_row( + backend, + quadraui::Rect::new(area.x, fill_y, area.width, unit_h), + "", + dim_fg, + panel_bg, + ); + fill_y += unit_h; + } + + // ── Separator ───────────────────────────────────────────────────────────── + if y < bottom { + let sep: String = std::iter::repeat_n('─', area_cols).collect(); + draw_ai_text_row( + backend, + quadraui::Rect::new(area.x, y, area.width, unit_h), + &sep, + dim_fg, + header_bg, + ); + y += unit_h; + } + + let input_top = y; + + // ── Input area (multi-line, grows with content) ──────────────────────────── + let (inp_bg, inp_fg) = if ai.input_active { + (input_active_bg, default_fg) + } else { + (panel_bg, dim_fg) + }; + let cursor = ai.input_cursor.min(input_chars.len()); + let cursor_line = cursor.checked_div(content_w).unwrap_or(0); + let cursor_col = if content_w > 0 { + cursor % content_w + } else { + cursor + }; + + if ai.input_active || !ai.input.is_empty() { + // Split input into visual chunks + let chunks: Vec<&[char]> = if input_chars.is_empty() { + vec![&[][..]] + } else { + input_chars.chunks(content_w).collect() + }; + for (line_idx, chunk) in chunks.iter().enumerate().take(input_line_count) { + if y >= bottom { + break; + } + // Prefix (" > " on first line, " " on continuations) + content, + // in one call — the row-blank, prefix-write, and content-write + // are always the same `inp_fg`/`inp_bg` pair, and + // `draw_status_bar` fills the whole row's background regardless + // of text length, so painting the concatenated text in a single + // call is behaviour-identical to a three-pass cell-by-cell + // version. + let pfx = if line_idx == 0 { " > " } else { " " }; + let content: String = chunk.iter().collect(); + let text = format!("{pfx}{content}"); + draw_ai_text_row( + backend, + quadraui::Rect::new(area.x, y, area.width, unit_h), + &text, + inp_fg, + inp_bg, + ); + // Cursor (inverted cell on the cursor line) + if ai.input_active && line_idx == cursor_line { + let cx = area.x + (pfx_len + cursor_col) as f32 * unit_w; + if cx < area.x + area.width { + let cursor_ch = input_chars.get(cursor).copied().unwrap_or(' '); + draw_ai_text_row( + backend, + quadraui::Rect::new(cx, y, unit_w, unit_h), + &cursor_ch.to_string(), + inp_bg, + inp_fg, + ); + } + } + y += unit_h; + } + } else if y < bottom { + // Placeholder when input is empty and not active + let placeholder = if ai.streaming { + " (waiting for response…)" + } else { + " Press i to type…" + }; + draw_ai_text_row( + backend, + quadraui::Rect::new(area.x, y, area.width, unit_h), + placeholder, + inp_fg, + inp_bg, + ); + } + + AiSidebarBands { + header: header_rect, + messages: messages_rect, + input: quadraui::Rect::new(area.x, input_top, area.width, (bottom - input_top).max(0.0)), + } +} + /// Build the cell grid for a single terminal session. /// /// Uses `TerminalSession::to_terminal()` to get the base snapshot from the diff --git a/src/tui_main/panels.rs b/src/tui_main/panels.rs index 983c5b98..128f5a94 100644 --- a/src/tui_main/panels.rs +++ b/src/tui_main/panels.rs @@ -1147,24 +1147,13 @@ pub(super) fn render_ext_sidebar( /// Render the AI assistant sidebar panel. /// -/// #635 (Stage 6b item C): widened from `buf: &mut ratatui::buffer::Buffer` -/// (the most raw-`Buffer` of the sidebar panels — no backend parameter at -/// all) to `&mut dyn quadraui::Backend`, one implementation shared by -/// `draw_frame` and `render_content` — the same shape the settings / -/// source-control / extensions sidebar renderers already converted to -/// (#605). Every plain-box row (`write_row`'s old two-pass `set_cell` -/// blank-then-overwrite) went through the same [`fill_row`] rule-row trick -/// those stages used; there was no chrome here `fill_row`/`fill_rect` -/// couldn't reproduce exactly. `quadraui::tui::draw_message_list` (the -/// message-history rasteriser) already had a `Backend::draw_message_list` -/// trait equivalent — it just wasn't being called through it — so that -/// swap needed no upstream change. One intentional, minor cosmetic -/// difference: the trait method sources the message list's background from -/// `TuiBackend::current_theme.background` internally rather than accepting -/// it as a parameter, so the message area's background is now -/// `theme.background` instead of the `theme.completion_bg` this used to -/// pass explicitly — same tolerance band as the `active_accent`/ -/// `selection_bg` gap noted elsewhere in this stage. +/// #730: delegates its entire paint to the shared +/// [`render::draw_ai_sidebar_panel`], the builder GTK's `render_content` +/// `PANEL_AI` arm now also calls — one implementation instead of two, the +/// same convergence #670's other four panel surfaces already went through. +/// `unit_w`/`unit_h` are `1.0, 1.0` here since `area` is already in +/// character cells (TUI's native coordinate space); GTK passes its pixel +/// `char_width`/`line_height` instead. pub(super) fn render_ai_sidebar( backend: &mut dyn quadraui::Backend, area: Rect, @@ -1180,170 +1169,13 @@ pub(super) fn render_ai_sidebar( return; }; - backend.set_theme(super::quadraui_tui::q_theme(theme)); - - let header_fg = theme.status_fg; - let header_bg = theme.status_bg; - let default_fg = theme.foreground; - let dim_fg = theme.line_number_fg; - let panel_bg = theme.completion_bg; - let input_bg = theme.fuzzy_selected_bg; - - let mut y = area.y; - - // ── Row 0: header ───────────────────────────────────────────────────────── - if y < area.y + area.height { - let hdr = if ai.streaming { - " \u{f0e5} AI ASSISTANT (thinking…)" - } else { - " \u{f0e5} AI ASSISTANT" - }; - fill_row(backend, area.x, y, area.width, hdr, header_fg, header_bg); - y += 1; - } - - // ── Compute input height (grows with content) ───────────────────────────── - let pfx_len = 3usize; // " > " / " " - let content_w = (area.width as usize).saturating_sub(pfx_len).max(1); - let input_chars: Vec = ai.input.chars().collect(); - let input_line_count = { - let raw = if input_chars.is_empty() { - 1 - } else { - input_chars.len().div_ceil(content_w) - }; - // cap so messages keep at least 3 rows - raw.min((area.height as usize).saturating_sub(5).max(1)) - }; - // +1 for separator row - let input_rows = input_line_count as u16 + 1; - let msg_area_height = area.height.saturating_sub(1 + input_rows); // 1 = header - - // ── Message history ─────────────────────────────────────────────────────── - let scroll = ai.scroll_top; - let wrap_w = content_w.saturating_sub(1).max(10); // slightly narrower for " " indent - let q_user_fg = render::to_quadraui_color(theme.keyword); - let q_asst_fg = render::to_quadraui_color(theme.string_lit); - let q_default_fg = render::to_quadraui_color(theme.foreground); - let q_panel_bg = render::to_quadraui_color(theme.completion_bg); - let mut rows: Vec = Vec::new(); - for msg in &ai.messages { - let is_user = msg.role == "user"; - let role_label = if is_user { "You:" } else { "AI:" }; - let role_fg = if is_user { q_user_fg } else { q_asst_fg }; - rows.push(quadraui::MessageRow::new(role_label, role_fg, 0.0)); - for line in msg.content.lines() { - if line.is_empty() { - rows.push(quadraui::MessageRow::new("", q_default_fg, 2.0)); - continue; - } - let chars: Vec = line.chars().collect(); - let mut pos = 0; - while pos < chars.len() { - let end = (pos + wrap_w).min(chars.len()); - let chunk: String = chars[pos..end].iter().collect(); - rows.push(quadraui::MessageRow::new(chunk, q_default_fg, 2.0)); - pos = end; - } - } - rows.push(quadraui::MessageRow::new("", q_panel_bg, 0.0)); // blank separator - } - - let total = rows.len(); - let start = scroll.min(total.saturating_sub(msg_area_height as usize)); - let msg_list = quadraui::MessageList { - id: quadraui::WidgetId::new("tui:ai:messages"), - rows, - scroll_top: start, - }; - // #635 (Stage 6b item C): `Backend::draw_message_list` was already a - // trait method (see this fn's doc comment for the one cosmetic - // difference in how it sources its background colour). - let q_rect = quadraui::Rect::new( + let q_area = quadraui::Rect::new( area.x as f32, - y as f32, + area.y as f32, area.width as f32, - msg_area_height as f32, + area.height as f32, ); - backend.draw_message_list(q_rect, &msg_list); - y += msg_area_height; - - // Fill any rows the message list didn't cover (when there are - // fewer messages than the visible area). - let painted = msg_list - .rows - .len() - .saturating_sub(start) - .min(msg_area_height as usize) as u16; - let mut fill_y = area.y + 1 + painted; - while fill_y < area.y + 1 + msg_area_height { - fill_row(backend, area.x, fill_y, area.width, "", dim_fg, panel_bg); - fill_y += 1; - } - - // ── Separator ───────────────────────────────────────────────────────────── - if y < area.y + area.height { - let sep: String = std::iter::repeat_n('─', area.width as usize).collect(); - fill_row(backend, area.x, y, area.width, &sep, dim_fg, header_bg); - y += 1; - } - - // ── Input area (multi-line, grows with content) ──────────────────────────── - let (inp_bg, inp_fg) = if ai.input_active { - (input_bg, default_fg) - } else { - (panel_bg, dim_fg) - }; - let cursor = ai.input_cursor.min(input_chars.len()); - let cursor_line = cursor.checked_div(content_w).unwrap_or(0); - let cursor_col = if content_w > 0 { - cursor % content_w - } else { - cursor - }; - - if ai.input_active || !ai.input.is_empty() { - // Split input into visual chunks - let chunks: Vec<&[char]> = if input_chars.is_empty() { - vec![&[][..]] - } else { - input_chars.chunks(content_w).collect() - }; - for (line_idx, chunk) in chunks.iter().enumerate().take(input_line_count) { - if y >= area.y + area.height { - break; - } - // Prefix (" > " on first line, " " on continuations) + content, - // in one `fill_row` call — the row-blank, prefix-write, and - // content-write were always the same `inp_fg`/`inp_bg` pair, so - // painting the concatenated text over the whole-row fill in a - // single call is behaviour-identical to the old three-pass - // `set_cell` version. - let pfx = if line_idx == 0 { " > " } else { " " }; - let content: String = chunk.iter().collect(); - let text = format!("{pfx}{content}"); - fill_row(backend, area.x, y, area.width, &text, inp_fg, inp_bg); - // Cursor (inverted cell on the cursor line) - if ai.input_active && line_idx == cursor_line { - let cx = area.x + pfx_len as u16 + cursor_col as u16; - if cx < area.x + area.width { - let cursor_ch = input_chars.get(cursor).copied().unwrap_or(' '); - fill_row(backend, cx, y, 1, &cursor_ch.to_string(), inp_bg, inp_fg); - } - } - y += 1; - } - } else { - // Placeholder when input is empty and not active - if y < area.y + area.height { - let placeholder = if ai.streaming { - " (waiting for response…)" - } else { - " Press i to type…" - }; - fill_row(backend, area.x, y, area.width, placeholder, inp_fg, inp_bg); - } - } + render::draw_ai_sidebar_panel(backend, q_area, ai, theme, 1.0, 1.0); } // ─── Debug sidebar panel ──────────────────────────────────────────────────────