//! Minimal-mode inline-overlay host (design K11 / §6.8). //! //! Renders the single active *prompt-anchored* dropdown — `@` file search, `/` //! slash command, or shell completion — directly above the prompt, growing the //! pinned live viewport to make room and shrinking + re-anchoring it to the //! bottom of the screen when the dropdown closes. (Modal overlays — permission, //! question, plan, rewind — land in PR 10; the command palette / model picker //! in a later PR.) //! //! ## Viewport sizing (the load-bearing part) //! //! The live viewport is **not bottom-pinned**: it sits //! directly after the committed conversation. The welcome card is committed at //! the top of a fresh screen (`welcome::maybe_commit_welcome` resets the //! viewport to row 0 + clears), and from there each committed block pushes the //! viewport down via `insert_before`; once it reaches the screen bottom, further //! commits scroll into native scrollback. //! //! When idle, the viewport is sized to *exactly* its content (todo panel + //! status + overlay + prompt) so the prompt sits right after the conversation //! with no gap, and the rest of the screen below stays empty. To show a dropdown //! / grow for the prompt, [`Terminal::set_viewport_height`] grows the viewport's //! **bottom edge downward** into that empty space — so committed content is never //! scrolled away and closing the overlay leaves **no blank band**. Only when the //! growth would overflow the screen bottom does it scroll committed rows up. The //! shrink path keeps the top fixed, so the prompt simply moves back up. No //! explicit bottom re-anchoring is needed (or wanted). //! //! `set_viewport_height` early-returns when the height is unchanged, so steady //! state is a no-op. //! //! [`Terminal::set_viewport_height`]: kigi_ratatui_inline::Terminal::set_viewport_height use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Style; use kigi_tui::app::PagerTerminal; use kigi_tui::app::agent_view::AgentView; use kigi_tui::app::app_view::{ActiveView, AppView}; use kigi_tui::appearance::LayoutConfig; use kigi_tui::minimal_api; use kigi_tui::render::SafeBuf as _; use kigi_tui::theme::Theme; use kigi_tui::views::prompt_widget::{PromptStyle, PromptWidget}; /// Which prompt-anchored dropdown is currently shown. /// /// Mirrors the coexistence order in `AgentView::draw`: `@` file search wins over /// `/` slash, which wins over shell completion. Only one is ever visible. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Kind { FileSearch, Slash, Completion, } /// The active dropdown and its capped item-row count, or `None` when no /// prompt-anchored dropdown is open (or the open one has nothing to show). /// /// `items_width` is the width the rows render at (minimal is flush-left, so /// the prompt/viewport width); slash rows wrap, so their count is line-based. fn active(prompt: &PromptWidget, items_width: u16) -> Option<(Kind, u16)> { use kigi_tui::views::completion_dropdown::MAX_VISIBLE_ROWS; use kigi_tui::views::file_search::dropdown::MAX_DROPDOWN_ROWS as FILE_MAX; use kigi_tui::views::slash_dropdown::desired_item_rows; // Precedence matches `AgentView::draw`: file search is checked first and, // when visible, suppresses the others even if it currently has 0 results. if prompt.file_search_visible() { let n = (prompt.file_search.result_count() as u16).min(FILE_MAX); return (n > 0).then_some((Kind::FileSearch, n)); } if prompt.slash_open() { let n = desired_item_rows(&prompt.slash_snapshot().matches, items_width); return (n > 0).then_some((Kind::Slash, n)); } if prompt.completion_dropdown_open() { let n = (minimal_api::prompt_suggestions(prompt).dropdown.items.len() as u16) .min(MAX_VISIBLE_ROWS); return (n > 0).then_some((Kind::Completion, n)); } None } /// Extra rows the active dropdown needs above the prompt (top border + items + /// bottom border), or 0 when none is open. The bottom border doubles as the gap /// row directly above the prompt, matching `render_dropdown_chrome`. pub fn overlay_rows(prompt: &PromptWidget, items_width: u16) -> u16 { active(prompt, items_width) .map(|(_, rows)| panel_rows(rows)) .unwrap_or(0) } /// Panel height for `item_rows` rendered rows: top border + items + bottom /// border. `item_rows` is assumed already capped and non-zero. fn panel_rows(item_rows: u16) -> u16 { item_rows + 2 } /// Moderate target height for a centered app-modal panel. Deliberately well /// below a full screen so closing the modal leaves only a small blank band /// (the band equals `target - base`); a screen-tall band was the "bunch of /// blank space" dogfooding complaint. const MINIMAL_APP_MODAL_ROWS: u16 = 18; /// Target live-viewport height for a centered app-modal: a moderate, /// bottom-anchored panel rather than the full screen. Never below the live /// region's `base`, never above the screen `ceiling`. fn app_modal_target(base: u16, ceiling: u16) -> u16 { MINIMAL_APP_MODAL_ROWS.clamp(base, ceiling) } /// Target live-viewport height for a prompt-replacing modal (permission / /// question / rewind): the `modal_h` rows of the modal, one status row, and the /// uncommitted live `tail_h` rows **above** it. /// /// Reserving room for the tail is load-bearing: a tool blocked on a permission / /// question is held uncommitted in the live tail (`is_pending_user_input`), so /// the tail is where its diff / command preview is drawn. Sizing to `modal_h + 1` /// alone collapses the tail to zero rows — the "Allow Edit to …?" prompt then /// shows with no visible diff. Floored at `base` (and at 3) so some live region /// always remains; capped at `ceiling` — when tail + modal overflow the screen, /// `live::draw_tail` bottom-anchors and clips the top. fn modal_target(tail_h: u16, modal_h: u16, base: u16, ceiling: u16) -> u16 { tail_h .saturating_add(modal_h) .saturating_add(1) // status row between the tail and the modal .max(base) .min(ceiling) .max(3) } /// Grow / shrink the live viewport to fit its content + the active overlay. /// /// The live region is **not** bottom-pinned: it sits /// directly after the committed conversation. `set_viewport_height` grows it /// *downward* into the empty space below when there's room — so an overlay never /// scrolls committed content into scrollback and closing it leaves **no blank /// band** — and only scrolls when the growth would overflow the screen bottom. /// As blocks commit, `insert_before` pushes the viewport down naturally; once it /// reaches the bottom, further commits scroll. A no-op when height is unchanged. pub fn sync_viewport(app: &mut AppView, terminal: &mut PagerTerminal) { let term_h = terminal.last_known_area().height; if term_h < 3 { return; } let width = terminal.viewport_area().width; let target = compute_target(app, term_h, width); let cur = terminal.viewport_area(); if cur.height == target { return; } // Content-anchored, NOT bottom-pinned. `sync_viewport` runs *before* // `commit_active` (see [`super::draw`]) and sizes to the POST-commit tail // ([`super::live::tail_height`]). When the conversation is short the prompt // sits right below the last block with blank space beneath it; once the // conversation fills the screen that "right after the content" position // *is* the bottom (content scrolls into native history as it grows, like a // shell). We deliberately do NOT force the viewport to the bottom edge — // that left a large blank gap under a short conversation (dogfood feedback). // // Two resize paths, gated on whether a commit is about to run: if will_commit(app) { // A commit follows. Only pre-set the viewport HEIGHT (to the post-commit // size) and keep the current top; `commit_active`'s `insert_before` then // does the rest — it prints the finalized block, scrolls the overflow // into native scrollback, clears the vacated rows, and repositions the // (correctly-sized) viewport to sit right after the block. We use // `set_viewport_area` rather than `set_viewport_height` here to skip the // latter's clear + grow-time scroll, which would be redundant work right // before `insert_before` performs its own clear/scroll. (This leaves the // stored `Viewport::Inline` height briefly out of sync with the area // height, but that is harmless: `set_viewport_height` judges grow/shrink // against the live area height, and it is resynced on the next frame.) terminal.set_viewport_area(Rect { height: target, ..cur }); } else { // No commit this frame (overlay open/close, idle prompt edits). // `set_viewport_height` is top-fixed — which keeps the viewport anchored // right after the content — and scrolls committed rows up into native // scrollback on a grow (preserving them) while clearing the vacated rows // on a shrink (wiping stale overlay/dropdown content). let _ = terminal.set_viewport_height(target); } } /// Whether [`super::commit::commit_active`] will print at least one block into /// native scrollback this frame (the shared [`super::commit::scan_frontier`] /// projection), and no centered app-modal is holding commits. Gates the resize /// path in [`sync_viewport`]: a commit's `insert_before` handles clearing + /// repositioning itself, so the resize must NOT clear; otherwise /// `set_viewport_height`'s clear is needed to wipe stale rows. fn will_commit(app: &AppView) -> bool { let ActiveView::Agent(id) = &app.active_view else { return false; }; let Some(agent) = app.agents.get(id) else { return false; }; if app_modal_active(agent) { return false; } let turn_running = agent.session.state.is_turn_running(); super::commit::scan_frontier(&agent.scrollback, turn_running).will_commit } /// Resolve the target viewport height for the active agent's overlay state. fn compute_target(app: &mut AppView, term_h: u16, width: u16) -> u16 { let minimal_live_rows = app.appearance.minimal_live_rows; let ceiling = term_h.saturating_sub(1).max(3); let base = minimal_live_rows.clamp(3, ceiling); // Ctrl+T "force-show" pin; effective visibility (auto-hide) is computed per // agent by `todo_panel_height`. let force_todos = minimal_api::minimal_show_todos(app); // Snapshot appearance-derived inputs before borrowing `agents` mutably. let style = super::live::prompt_style(&app.appearance); // Committed appearance (timestamps off) so the measured tail height matches // exactly what `draw_tail` renders. let commit_app = super::commit::committed_appearance(&app.appearance); // Minimal is flush-left (W-38): prompt-replacing modals span the live // region's full width (no outer horizontal padding), so measure their // height at that same width — it must match `live::draw_live`'s // `modal_area` or the viewport is sized for a different text wrap. let content_w = width as usize; let ActiveView::Agent(id) = &app.active_view else { return base; }; let id = *id; let Some(agent) = app.agents.get_mut(&id) else { return base; }; // A below-prompt list panel (resume / mcps) renders as a simple list under // the input bar; size to its exact content height (capped) so the footer // sits directly under the last row and the list scrolls internally past the // screen. Checked before the app-modal branch since the session picker is // also an `active_modal`. if let Some(kind) = super::panel::active(agent) { return super::panel::panel_height(agent, kind, width, ceiling); } // A centered app-modal (command palette / settings / pickers) or the // extensions modal (hooks / plugins / marketplace / skills) reuses the // full-TUI popup renderer, which fills whatever area it's given. Grow to a // moderate, bottom-anchored height — NOT the full ceiling. Growing to the // ceiling and shrinking on close left a *screen-tall* blank band above the // prompt (committed rows scrolled into native scrollback can't be pulled // back). A capped panel keeps that band small while still giving the // list/editor room (its inner content scrolls). if app_modal_active(agent) || minimal_api::extensions_modal(agent).is_some() { return app_modal_target(base, ceiling); } // A prompt-replacing modal (permission / question / rewind) takes the bottom // region in place of the prompt. Size to fit the uncommitted live tail // ABOVE the modal too (+ the status row between them): a tool blocked on a // permission / question is held in the tail (`is_pending_user_input`, see // `commit::is_committable`), so the tail is exactly where its diff / command // preview lives — without reserving room for it the viewport collapses to // just the modal and the "Allow Edit to …?" prompt shows with no visible // diff (the mid-stream permission hold of design §6.8 / risk #3). Capped at // `ceiling`; when tail + modal overflow, `draw_tail` bottom-anchors and // clips the top. if let Some(modal) = active_modal(agent) { let modal_h = modal_height(modal, agent, term_h, content_w); let tail_h = super::live::tail_height(agent, width, &commit_app); return modal_target(tail_h, modal_h, base, ceiling); } // Otherwise size to fit the prompt (it expands as you type) plus any // prompt-anchored dropdown. let overlay_h = overlay_rows(&agent.prompt, width); let cap = ceiling.saturating_sub(overlay_h + 1).max(1); let prompt_h = agent .prompt .desired_height(width, &style, false, cap) .max(1); // Size the viewport to exactly its content — tail (uncommitted streaming // output) + todo panel + status + overlay + prompt — so the prompt sits // directly after the conversation with no gap, whether idle or mid-turn. // When a turn is "thinking" the tail is empty, so the prompt stays right // under the content instead of floating below a fixed empty region; as // output streams the tail grows and the viewport grows downward with it. // The region is not bottom-pinned, so the rest of the screen below stays // empty (the app "owns" the window from the top down). let tail_h = super::live::tail_height(agent, width, &commit_app); let todos_h = super::todo::todo_panel_height(agent, force_todos); // Below the prompt sits either the dropdown overlay or the 1-row info bar // (model · context usage · turn time/tokens); reserve at least the info row // when no dropdown is open so it isn't clipped / doesn't scroll content. let below_h = overlay_h.max(1); content_target(tail_h, todos_h, below_h, prompt_h, ceiling) } /// Live-viewport height sized to exactly its content: tail + todo panel + status /// row + overlay + prompt. Floored at 2 (status + prompt), capped at the screen. fn content_target(tail_h: u16, todos_h: u16, overlay_h: u16, prompt_h: u16, ceiling: u16) -> u16 { tail_h .saturating_add(todos_h) .saturating_add(1) // status row .saturating_add(overlay_h) .saturating_add(prompt_h) .clamp(2, ceiling) } /// Render the active prompt-anchored dropdown into the band directly above /// `prompt_area`, reusing the shared dropdown chrome + item renderers so the /// look matches the full TUI. `viewport_area` is the whole live region (used for /// panel width / horizontal padding). A no-op when no dropdown is open. pub fn render( buf: &mut Buffer, viewport_area: Rect, prompt_area: Rect, prompt: &mut PromptWidget, layout_cfg: &LayoutConfig, compact: bool, theme: &Theme, ) { let Some((kind, item_rows)) = active(prompt, prompt_area.width) else { return; }; // Sync the scroll window before measuring/rendering (mirrors `AgentView`). if kind == Kind::FileSearch { prompt.file_search.ensure_visible(item_rows as usize); } let item_count = match kind { Kind::FileSearch => prompt.file_search.result_count(), Kind::Slash => prompt.slash_snapshot().matches.len(), Kind::Completion => minimal_api::prompt_suggestions(prompt).dropdown.items.len(), }; let Some(items_rect) = minimal_api::dropdown_chrome_items( buf, item_count, item_rows, None, // no inline prompt area; anchor straight below `prompt_area` prompt_area, viewport_area, layout_cfg, compact, true, // minimal: anchor the dropdown *below* the input bar theme, ) else { return; }; match kind { Kind::FileSearch => { kigi_tui::views::file_search::dropdown::render_dropdown( buf, items_rect, &prompt.file_search, theme, ); } Kind::Slash => { let snap = prompt.slash_snapshot(); let hovered = prompt.slash_hovered(); kigi_tui::views::slash_dropdown::render_dropdown( buf, items_rect, &snap, hovered, theme, ); } Kind::Completion => { kigi_tui::views::completion_dropdown::render_dropdown( buf, items_rect, &minimal_api::prompt_suggestions(prompt).dropdown, theme, ); } } } // ─────────────────────────── modal overlays (PR10) ─────────────────────────── // // Unlike the prompt-anchored dropdowns above, these modals *replace* the prompt: // they occupy the bottom region and the user interacts with them directly. Keys // already route to the shared permission / question / rewind handlers (minimal // did not change input routing), so this is a render + sizing concern only. // // The permission, question, rewind-picker, and cancel-turn confirm modals are // all hosted here. Plan approval is hosted separately via [`plan`] (its full-TUI // surface is a fullscreen line-viewer + live prompt, so it gets its own minimal // treatment rather than a `render_*` reuse). /// A prompt-replacing modal overlay. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Modal { Permission, Question, Rewind, /// The "subagents are still running — stop them?" confirm shown when /// cancelling a turn with running subagents (`AgentView::cancel_turn_view`). Cancel, /// Plan approval (`AgentView::plan_approval_view`) — rendered compactly by /// [`super::plan`] in place of the full TUI's fullscreen line viewer. Plan, } /// The active prompt-replacing modal, in the full-TUI render precedence /// (cancel-confirm > plan > permission > question > rewind), or `None`. pub fn active_modal(agent: &AgentView) -> Option { // The cancel-turn confirm is checked first to match the input router, which // intercepts keys for `cancel_turn_view` ahead of the question view // (`AgentView::handle_input`). if minimal_api::cancel_turn_view(agent).is_some() { return Some(Modal::Cancel); } // Plan approval routes through the line viewer (kept open in minimal) and is // mutually exclusive with permission/question in practice; check it next. if minimal_api::plan_approval_view(agent).is_some() { return Some(Modal::Plan); } if !agent.permission_queue.is_empty() { return Some(Modal::Permission); } if minimal_api::question_view(agent).is_some() { return Some(Modal::Question); } if minimal_api::rewind_state(agent).is_some() { return Some(Modal::Rewind); } None } /// Desired modal height in rows. `screen_h` must be the **full terminal height** /// so the views' internal caps compute against the real screen, not the small /// live region (design Issue 5). pub fn modal_height(modal: Modal, agent: &mut AgentView, screen_h: u16, content_w: usize) -> u16 { match modal { Modal::Permission => agent .permission_queue .front() .map(|p| { kigi_tui::views::permission_view::permission_view_height(p, screen_h, content_w) }) .unwrap_or(0), Modal::Question => { let input_mode = minimal_api::question_view(agent).is_some_and(|qv| { qv.focus == kigi_tui::views::question_view::QuestionFocus::InputMode }); let editor_extra = if input_mode { question_editor_h( agent, content_w as u16, question_editor_cap(screen_h), &Theme::current(), ) .saturating_sub(1) } else { 0 }; minimal_api::question_view_mut(agent) .map(|qv| { kigi_tui::views::question_view::question_view_height(qv, screen_h, content_w) .saturating_add(editor_extra) }) .unwrap_or(0) } Modal::Rewind => minimal_api::rewind_state(agent) .map(|rw| kigi_tui::views::rewind::rewind_overlay_height(&rw.phase, screen_h)) .unwrap_or(0), Modal::Cancel => { if minimal_api::cancel_turn_view(agent).is_some() { kigi_tui::views::modal::cancel_turn_panel_height(screen_h) } else { 0 } } Modal::Plan => super::plan::height(agent), } } /// Render the active modal into `area` (the live region's full width — /// flush-left, W-38). `screen_h` is the full terminal height — the same /// value `modal_height` sized the area with. Returns the text cursor when /// the modal hosts an inline editor (permission follow-up / question input /// mode), else `None`. pub fn render_modal( buf: &mut Buffer, area: Rect, modal: Modal, agent: &mut AgentView, theme: &Theme, screen_h: u16, ) -> Option<(u16, u16)> { match modal { Modal::Permission => render_permission(buf, area, agent, theme), Modal::Question => render_question(buf, area, agent, theme, screen_h), Modal::Rewind => { if let Some(rw) = minimal_api::rewind_state(agent) { kigi_tui::views::rewind::render_rewind_overlay(buf, area, &rw.phase, true); } None } Modal::Cancel => { // The prompt is always focused in minimal, so the confirm is too. // `cancel_turn_view` (shared) and `cancel_turn_buttons_mut` (exclusive) // can't both be borrowed from the agent at once through the facade, so // render the hit-test rects into a local Vec and store them back after. let mut buttons: Vec = Vec::new(); let drawn = if let Some(ctv) = minimal_api::cancel_turn_view(agent) { kigi_tui::views::modal::render_cancel_turn_panel( buf, area, ctv, true, &mut buttons, ); true } else { false }; if drawn { *minimal_api::cancel_turn_buttons_mut(agent) = buttons; } None } Modal::Plan => super::plan::render(buf, area, agent, theme), } } // ─────────────────────────── app-modals (PR13 / PR15) ──────────────────────── // // A second family of overlays lives in `AgentView::active_modal` (the full-TUI // `ActiveModal` enum) rather than the per-feature fields the [`Modal`]s above // read: the command palette, keyboard-shortcuts help, settings editor, model / // session / doc pickers, memory browser, etc. These are *centered popups* with // their own rich renderers, so — unlike the bottom-anchored modals — minimal // hosts them by growing the live viewport to (near) the whole screen and // reusing the exact full-TUI renderer ([`AgentView::draw_active_modal`]). // Committed scrollback scrolls up out of the way during the grow (and stays in // native history); the popup centers in the grown region. // // Input already routes to the shared `handle_modal_key` path (minimal only // swaps the *render* path), so hosting here is purely a render + sizing concern. /// Whether an `AgentView::active_modal` (command palette / shortcuts help / /// settings / pickers / …) is open. Minimal hosts these as centered overlays. pub fn app_modal_active(agent: &AgentView) -> bool { agent.active_modal.is_some() } /// Render the active centered app-modal into `area`, reusing the exact full-TUI /// [`AgentView::draw_active_modal`] dispatch so look + behavior match every /// mode. Returns whether anything was drawn. pub fn render_app_modal( buf: &mut Buffer, area: Rect, agent: &mut AgentView, compact: bool, ) -> bool { if agent.active_modal.is_none() { return false; } minimal_api::draw_active_modal(agent, area, buf, Theme::current(), compact); true } fn render_permission( buf: &mut Buffer, area: Rect, agent: &mut AgentView, theme: &Theme, ) -> Option<(u16, u16)> { let perm = agent.permission_queue.front()?; // Clone so the immutable borrow of `agent.prompt` ends before the mutable // `agent.prompt.draw` below. let followup = agent.prompt.text().to_string(); let result = kigi_tui::views::permission_view::render_permission_view( buf, area, perm, &followup, minimal_api::hovered_permission_item(agent), theme, true, ); // Follow-up (reject-with-text) hosts an inline editor; draw it plainly // (minimal skips the full TUI's accent-bar/scrollbar chrome). let iarea = result.inline_prompt?; let style = inline_input_style(theme); let height = (area.y + area.height) .saturating_sub(iarea.y) .saturating_sub(1) .max(1); let rect = Rect { x: iarea.text_x, y: iarea.y, width: iarea.text_w, height, }; agent.prompt.draw(buf, rect, None, &style, None).cursor_pos } fn render_question( buf: &mut Buffer, area: Rect, agent: &mut AgentView, theme: &Theme, screen_h: u16, ) -> Option<(u16, u16)> { use kigi_tui::views::question_view::{QUESTION_VIEW_HPAD, QuestionFocus}; let input_mode = minimal_api::question_view(agent) .map(|qv| qv.focus == QuestionFocus::InputMode) .unwrap_or(false); let input_h: u16 = if input_mode { question_editor_h( agent, area.width, question_editor_cap(screen_h).min(area.height.saturating_sub(1).max(1)), theme, ) } else { 0 }; let q_area = Rect { height: area.height.saturating_sub(input_h), ..area }; let content_w = area.width.saturating_sub(QUESTION_VIEW_HPAD) as usize; // Clamp any stale scroll offset to the rendered viewport (mirrors // `AgentView::draw`). Computed in an inner scope so the immutable borrows // end before the `&mut clamp_scroll`. if let Some(qv) = minimal_api::question_view_mut(agent) { let vis = qv.questions.get(qv.active_tab).map(|question| { kigi_tui::views::question_view::visible_options_height( question, q_area.height, content_w, qv.focused_preview(), qv.fullscreen, qv.cached_desc_cap, qv.cached_preview_cap, ) }); if let Some(vis) = vis { qv.clamp_scroll(vis, content_w); } } if let Some(qv) = minimal_api::question_view(agent) { kigi_tui::views::question_view::render_question_view( buf, q_area, qv, minimal_api::hovered_question_item(agent), theme, true, ); } if input_mode { let row_y = area.y + area.height.saturating_sub(input_h); let is_multi = minimal_api::question_view(agent) .and_then(|qv| qv.questions.get(qv.active_tab)) .and_then(|q| q.multi_select) .unwrap_or(false); let freeform_sel = minimal_api::question_view(agent) .and_then(|qv| qv.per_question_freeform_selected.get(qv.active_tab)) .copied() .unwrap_or(false); let embed = kigi_tui::views::modal_window::embedded_row_style(theme, true); let fg = |normal| embed.map_or(normal, |e| e.fg(normal)); let marker = if is_multi { (if freeform_sel { "[x]" } else { "[ ]" }).to_string() } else if freeform_sel { format!("({})", kigi_tui::glyphs::filled_dot()) } else { "(\u{25cb})".to_string() }; let marker_style = if freeform_sel { Style::default() .fg(fg(theme.text_primary)) .add_modifier(ratatui::style::Modifier::BOLD) } else { Style::default().fg(fg(theme.gray)) }; let accent = Style::default().fg(fg(theme.accent_user)); buf.set_span_safe( area.x + 3, row_y, &ratatui::text::Span::styled("z ", accent), 2, ); buf.set_span_safe( area.x + 5, row_y, &ratatui::text::Span::styled(format!("{marker} "), marker_style), 4, ); buf.set_span_safe( area.x + 9, row_y, &ratatui::text::Span::styled(kigi_tui::glyphs::prompt_arrow(), accent), 2, ); let style = inline_input_style(theme); let editor = Rect { x: area.x + 11, y: row_y, width: kigi_tui::views::question_view::inline_text_width(area.width), height: input_h, }; return agent .prompt .draw(buf, editor, None, &style, None) .cursor_pos; } None } /// Height cap for the inline question editor — the full TUI's policy /// (`agent_view/render.rs`, `inline_prompt_max`). fn question_editor_cap(screen_h: u16) -> u16 { ((screen_h as u32) / 3).clamp(3, 15) as u16 } /// Desired height of the inline question editor (InputMode), bounded by /// `cap`. fn question_editor_h(agent: &AgentView, area_w: u16, cap: u16, theme: &Theme) -> u16 { let text_w = kigi_tui::views::question_view::inline_text_width(area_w); agent .prompt .desired_height(text_w, &inline_input_style(theme), false, cap.max(1)) .max(1) } /// Chromeless prompt style for a modal's inline editor (no prefix / accent / /// borders — the modal supplies its own framing). fn inline_input_style(theme: &Theme) -> PromptStyle { PromptStyle { focused: true, show_prefix: false, vpad_top: 0, compact: false, chrome: false, chrome_pad_left: 0, chrome_pad_right: 0, bg_override: Some(theme.bg_visual), accent_color_override: None, border_color_override: None, prefix_override: None, placeholder_override: None, show_accent_line: false, show_borders: false, title: None, image_preview: true, } } #[cfg(test)] mod tests { use super::*; use kigi_tui::views::suggestion_controller::CompletionItemParsed; fn completion_item() -> CompletionItemParsed { // Functional update: only the semantic fields; new optional item // fields (e.g. the replace-range pair) default without breaking // this out-of-crate literal again. CompletionItemParsed { display: "item".into(), insert_text: "item".into(), ..Default::default() } } /// Build an agent with an active single-select question in InputMode and /// the given freeform text loaded into the prompt. fn question_input_agent(text: &str) -> AgentView { use kigi_tui::views::prompt_widget::StashedPrompt; use kigi_tui::views::question_view::{Question, QuestionOption, QuestionViewState}; let mut agent = minimal_api::test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp")); let mut qv = QuestionViewState::new( "tc-1".into(), vec![Question { question: "Pick one?".into(), options: vec![QuestionOption { label: "Alpha".into(), description: String::new(), preview: None, id: None, }], multi_select: Some(false), id: None, }], StashedPrompt::default(), ); qv.per_question_cursor[0] = qv.questions[0].options.len(); let _stashed = qv.activate_freeform_input(); minimal_api::set_question_view(&mut agent, Some(qv)); agent.prompt.set_text(text); agent } /// Regression: the editor height was hardcoded to 1 row (only the last /// typed line was visible) and InputMode dissolved the freeform row into /// a bare text line without its `z (·) ❯` prefix. #[test] fn question_input_mode_editor_grows_and_keeps_row_prefix() { let screen_h = 40u16; let content_w = 80usize; let mut single = question_input_agent("one line"); let base_h = modal_height(Modal::Question, &mut single, screen_h, content_w); let mut multi = question_input_agent("line one\nline two\nline three"); let multi_h = modal_height(Modal::Question, &mut multi, screen_h, content_w); assert_eq!( multi_h, base_h + 2, "editor must reserve one extra row per extra text line" ); // Render and audit the editor rows. let theme = Theme::terminal_default(); let area = Rect::new(0, 0, content_w as u16, multi_h); let mut buf = Buffer::empty(area); let cursor = render_question(&mut buf, area, &mut multi, &theme, screen_h); assert!(cursor.is_some(), "InputMode returns the editor cursor"); // Editor occupies the last 3 rows; its first row carries the prefix. let editor_top = multi_h - 3; let row_text: String = (0..area.width) .map(|x| { buf.cell((x, editor_top)) .map(|c| c.symbol().to_string()) .unwrap_or_default() }) .collect(); assert!( row_text.contains('z') && row_text.contains("(\u{25cf})"), "editor row must keep the freeform-row `z (\u{25cf})` prefix, got {row_text:?}" ); assert!( row_text.contains("line one"), "first text line renders on the first editor row, got {row_text:?}" ); let all_text: String = (0..area.height) .flat_map(|y| { (0..area.width).map(move |x| (x, y)).map(|pos| { buf.cell(pos) .map(|c| c.symbol().to_string()) .unwrap_or_default() }) }) .collect(); for line in ["line one", "line two", "line three"] { assert!(all_text.contains(line), "{line:?} must be visible"); } } /// Regression (Bugbot): the renderer used to cap the editor only by /// `area.height - 1`, so with text longer than the reserved cap it drew /// editor rows over the question list. #[test] fn question_editor_render_cap_matches_reserved_cap() { let screen_h = 40u16; // cap = 13 let content_w = 80usize; let cap = question_editor_cap(screen_h); assert_eq!(cap, 13); let long_text = (1..=30) .map(|i| format!("line {i}")) .collect::>() .join("\n"); let mut agent = question_input_agent(&long_text); let one_line = modal_height( Modal::Question, &mut question_input_agent("x"), screen_h, content_w, ); let capped = modal_height(Modal::Question, &mut agent, screen_h, content_w); assert_eq!( capped, one_line + (cap - 1), "reserved rows must stop at the shared cap" ); let theme = Theme::terminal_default(); let area = Rect::new(0, 0, content_w as u16, capped); let mut buf = Buffer::empty(area); render_question(&mut buf, area, &mut agent, &theme, screen_h); let all_text: String = (0..area.height) .flat_map(|y| { (0..area.width).map(move |x| (x, y)).map(|pos| { buf.cell(pos) .map(|c| c.symbol().to_string()) .unwrap_or_default() }) }) .collect(); assert!( all_text.contains("Pick one?"), "question chrome must stay visible above the capped editor" ); // The editor's first row (prefix row) sits exactly `cap` rows from // the bottom. let editor_top = capped - cap; let row_text: String = (0..area.width) .map(|x| { buf.cell((x, editor_top)) .map(|c| c.symbol().to_string()) .unwrap_or_default() }) .collect(); assert!( row_text.contains('z') && row_text.contains("line 1"), "editor top row must be the prefix row at the capped offset, got {row_text:?}" ); } #[test] fn panel_rows_adds_borders() { assert_eq!(panel_rows(1), 3); assert_eq!(panel_rows(8), 10); } #[test] fn fresh_prompt_has_no_overlay() { let pw = PromptWidget::new(); assert_eq!(overlay_rows(&pw, 80), 0); assert!(active(&pw, 80).is_none()); } #[test] fn open_completion_dropdown_reports_rows() { let mut pw = PromptWidget::new(); // Open the shell-completion dropdown with three items. The completion // dropdown's fields are crate-visible, so we can drive it directly // without a full prompt key sequence. minimal_api::prompt_suggestions_mut(&mut pw).dropdown.open = true; minimal_api::prompt_suggestions_mut(&mut pw).dropdown.items = vec![completion_item(), completion_item(), completion_item()]; assert_eq!(active(&pw, 80), Some((Kind::Completion, 3))); assert_eq!(overlay_rows(&pw, 80), 5); // 3 items + 2 borders } #[test] fn completion_dropdown_caps_item_rows() { use kigi_tui::views::completion_dropdown::MAX_VISIBLE_ROWS; let mut pw = PromptWidget::new(); minimal_api::prompt_suggestions_mut(&mut pw).dropdown.open = true; minimal_api::prompt_suggestions_mut(&mut pw).dropdown.items = (0..(MAX_VISIBLE_ROWS as usize + 5)) .map(|_| completion_item()) .collect(); assert_eq!(overlay_rows(&pw, 80), MAX_VISIBLE_ROWS + 2); } #[test] fn empty_open_dropdown_reports_nothing() { let mut pw = PromptWidget::new(); minimal_api::prompt_suggestions_mut(&mut pw).dropdown.open = true; // open but no items assert_eq!(overlay_rows(&pw, 80), 0); assert!(active(&pw, 80).is_none()); } #[test] fn content_target_fits_content_with_no_gap() { // Viewport = tail + todos + status(1) + overlay + prompt — no base // floor, so the prompt sits right after the conversation. Idle (tail 0, // empty prompt) is just status + prompt. assert_eq!(content_target(0, 0, 0, 1, 40), 2); // status + 1-row prompt assert_eq!(content_target(0, 3, 0, 1, 40), 5); // + 3 todo rows assert_eq!(content_target(0, 3, 5, 2, 40), 11); // + overlay(5) + 2-row prompt // The streaming tail grows the viewport (no fixed empty gap while // "thinking": tail 0 → just status + prompt). assert_eq!(content_target(6, 0, 0, 1, 40), 8); // tail(6) + status + prompt // Floored at 2 (status + prompt) and capped at the screen ceiling. assert_eq!(content_target(0, 0, 0, 0, 40), 2); assert_eq!(content_target(50, 0, 0, 0, 20), 20); } #[test] fn app_modal_target_is_capped_not_ceiling() { // Tall screen: a moderate bottom panel, never the whole screen — so the // band left on close (target - base) stays small. assert_eq!(app_modal_target(10, 49), MINIMAL_APP_MODAL_ROWS); // Short screen: clamps down to what fits. assert_eq!(app_modal_target(10, 14), 14); // A larger live region wins (never shrink the modal below base). assert_eq!(app_modal_target(24, 49), 24); } #[test] fn modal_target_reserves_room_for_the_pending_tail() { // Regression: an edit awaiting permission is held uncommitted in the // tail, so the permission modal must grow to show that diff ABOVE it, // not collapse to just the prompt. tail(10) + modal(8) + status(1) = 19, // which fits under the ceiling. assert_eq!(modal_target(10, 8, 6, 40), 19); // No uncommitted tail (e.g. a bash permission with nothing streamed): // modal + status only — identical to the pre-fix behavior, no regression. assert_eq!(modal_target(0, 8, 6, 40), 9); // A tall pending diff + modal overflow the screen → capped at the // ceiling; `draw_tail` then bottom-anchors and clips the top of the diff. assert_eq!(modal_target(100, 8, 6, 40), 40); // Tiny modal, no tail → floored at `base` so some live region remains. assert_eq!(modal_target(0, 1, 6, 40), 6); } #[test] fn content_target_clamps_to_screen() { // Content taller than the screen clamps to the ceiling (then the tail // scrolls / clips); a tiny terminal still yields at least the floor. assert_eq!(content_target(30, 0, 0, 1, 24), 24); assert_eq!(content_target(5, 0, 0, 1, 2), 2); } }