Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
1027 lines
42 KiB
Rust
1027 lines
42 KiB
Rust
//! 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<Modal> {
|
||
// 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<Rect> = 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::<Vec<_>>()
|
||
.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);
|
||
}
|
||
}
|