Sampler / inference (PRD F3):
- kimi_compat.rs: single adaptation point for the Kimi chat/completions
dialect (thinking-field mapping, model_id stripping, empty-content
tool-call message fix, stream_options.include_usage), with kimi-cli
source citations
- Rate-limit handling reworked for Kimi/Moonshot semantics; UA kigi/{version}
- /models replaces the xAI models-v2 endpoint everywhere; idle model
refresh carries X-Msh-* device headers only (X-XAI-Token-Auth and
x-grok-client-mode/CLIENT_MODE_HEADER machinery deleted)
Cloud-surface excision (PRD §5, zero-egress):
- remote/ conversations lane, cli-chat-proxy-types crate, prod/ dir,
share command, credit bar: deleted (single local session lane;
paginate() replaces merge_and_paginate)
- Subscription/tier gate stack deleted end-to-end: AppView
gate/tier/team/ZDR fields, app/subscription.rs watch loop,
dispatch/billing.rs paywall + SuperGrok upsell, free-usage-exhausted
chain, tier-restricted commands, GateInfo, RemoteSettings gate fields,
SettingsUpdateNotification gate fields
- /privacy + coding-data-sharing setting deleted (backed by a dead xAI
RPC; Kigi is zero-egress — nothing to share or retain remotely)
Auth UX correctness (user-reported):
- Device-flow fixtures now mirror the live Kimi payload shape
(https://www.kimi.com/code/authorize_device?user_code=..., verified
against auth.kimi.com); the fabricated auth.kimi.com/device?code=...
URLs are gone
- open_browser_detached is a no-op under cfg(test): unit tests drove
wiremock fixture URLs into the real browser (root cause of the
"garbage mock link" ABCD-1234 tabs)
- Welcome/pager-minimal rebrand: Grok Build -> Kigi, grok.com ->
kimi.com, "Sign in to Grok" -> "Sign in to Kimi"
1960 lines
69 KiB
Rust
1960 lines
69 KiB
Rust
//! Agent view layout and rendering helpers.
|
|
//!
|
|
//! This module provides:
|
|
//! - [`AgentViewLayout`] — pure layout computation (screen area → pane rects)
|
|
//! - [`ActivePane`] / [`PaneAreas`] — pane identity and hit-testing
|
|
//! - Overlay helpers — small focused functions for selection/hover chrome
|
|
//! - [`build_hints`] — shortcuts bar hint generation
|
|
//!
|
|
//! The actual rendering orchestration lives in [`AgentView::draw()`](crate::app::agent_view::AgentView::draw),
|
|
//! which calls shared widgets (StatusBar, ScrollbackPane, PromptWidget,
|
|
//! ShortcutsBar) and uses these helpers for the agent-specific glue.
|
|
use crate::actions::{ActionId, ActionRegistry, When};
|
|
use crate::appearance::{LayoutConfig, ScrollbarConfig};
|
|
use crate::render::SafeBuf;
|
|
use crate::render::scrollbar::render_scrollbar_styled;
|
|
use crate::scrollback::layout::HorizontalLayout;
|
|
use crate::scrollback::search::ScrollbackSearchState;
|
|
use crate::scrollback::selection::SelectionBox;
|
|
use crate::scrollback::state::ScrollbackState;
|
|
use crate::theme::Theme;
|
|
use crate::views::prompt_widget::PromptWidget;
|
|
use crate::views::shortcuts_bar::HintItem;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
|
use ratatui::style::Style;
|
|
use ratatui::text::Span;
|
|
use ratatui::widgets::{Block, Padding, Widget};
|
|
/// Which pane is currently active in the agent view.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum ActivePane {
|
|
#[default]
|
|
Scrollback,
|
|
Todo,
|
|
Queue,
|
|
Prompt,
|
|
Tasks,
|
|
Catalog,
|
|
}
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
|
pub enum InputMode {
|
|
#[default]
|
|
Vim,
|
|
Simple,
|
|
}
|
|
/// Cached pane areas from the last render, used for mouse hit-testing.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct PaneAreas {
|
|
pub scrollback: Rect,
|
|
pub todo: Rect,
|
|
pub queue: Rect,
|
|
pub prompt: Rect,
|
|
pub tasks: Rect,
|
|
pub catalog: Rect,
|
|
}
|
|
impl PaneAreas {
|
|
/// Determine which pane a screen position falls in, if any.
|
|
pub fn hit_test(&self, col: u16, row: u16) -> Option<ActivePane> {
|
|
let pos = (col, row).into();
|
|
if self.tasks.area() > 0 && self.tasks.contains(pos) {
|
|
return Some(ActivePane::Tasks);
|
|
}
|
|
if self.catalog.area() > 0 && self.catalog.contains(pos) {
|
|
return Some(ActivePane::Catalog);
|
|
}
|
|
if self.todo.area() > 0 && self.todo.contains(pos) {
|
|
return Some(ActivePane::Todo);
|
|
}
|
|
if self.queue.area() > 0 && self.queue.contains(pos) {
|
|
return Some(ActivePane::Queue);
|
|
}
|
|
if self.scrollback.contains(pos) {
|
|
return Some(ActivePane::Scrollback);
|
|
}
|
|
if self.prompt.contains(pos) {
|
|
return Some(ActivePane::Prompt);
|
|
}
|
|
None
|
|
}
|
|
}
|
|
/// Terminals at or below this height suppress the optional rows above the
|
|
/// prompt (plugin CTA, follow-ups, banner/tip) so the prompt and scrollback
|
|
/// are never starved.
|
|
pub const SHORT_TERMINAL_ROWS: u16 = 16;
|
|
/// Auto-compact threshold: at or below this height the render-value compact
|
|
/// flag is forced on. Deliberately above [`SHORT_TERMINAL_ROWS`], which stays
|
|
/// the hard-degradation gate (tip-row renderability, CTA/follow-up trims).
|
|
pub const AUTO_COMPACT_MAX_ROWS: u16 = 20;
|
|
const _: () = assert!(SHORT_TERMINAL_ROWS < AUTO_COMPACT_MAX_ROWS);
|
|
/// Render-value derivation for compact mode: the user setting, force-enabled
|
|
/// while the terminal is [`AUTO_COMPACT_MAX_ROWS`] or shorter (auto-compact).
|
|
///
|
|
/// Derived only — never persisted and never written back to the user setting
|
|
/// (`current_ui.compact_mode` / the render cache / disk), so growing the
|
|
/// window restores the user's choice. `terminal_rows == 0` means "not yet
|
|
/// measured" and never forces compact.
|
|
pub fn effective_compact(user_compact: bool, terminal_rows: u16) -> bool {
|
|
user_compact || (terminal_rows > 0 && terminal_rows <= AUTO_COMPACT_MAX_ROWS)
|
|
}
|
|
/// Computed screen layout for the agent view.
|
|
///
|
|
/// Pure data — no rendering. Computed from screen area + appearance config +
|
|
/// prompt height + todo height. Shared widgets use these rects to render into.
|
|
pub struct AgentViewLayout {
|
|
pub status_bar: Rect,
|
|
/// Startup terminal-warning banner (between status bar and bg tasks/scrollback).
|
|
pub startup_warnings: Rect,
|
|
pub tasks: Rect,
|
|
pub catalog: Rect,
|
|
pub scrollback: Rect,
|
|
pub todo: Rect,
|
|
pub queue: Rect,
|
|
/// Inline /btw side question panel (above queue / turn status / prompt).
|
|
pub btw: Rect,
|
|
pub turn_status: Rect,
|
|
/// Banner rect above the prompt (mode-switch banner, ephemeral tips).
|
|
pub banner: Rect,
|
|
/// Follow-up suggestion chips row (below the plugin CTA, above the prompt).
|
|
pub follow_ups: Rect,
|
|
pub prompt: Rect,
|
|
pub shortcuts: Rect,
|
|
/// Scrollback area narrowed for scrollbar (content rendering uses this).
|
|
pub scrollback_content: Rect,
|
|
/// Scrollbar track position (x coordinate).
|
|
pub scrollbar_x: u16,
|
|
/// Timeline rail left edge (only meaningful when `timeline_width > 0`;
|
|
/// the rail's right edge lands on the scrollbar column, which the rail
|
|
/// replaces).
|
|
pub timeline_x: u16,
|
|
/// Columns reserved for the timeline rail (0 = rail hidden). Non-zero
|
|
/// also means the scrollbar does not render this frame.
|
|
pub timeline_width: u16,
|
|
}
|
|
impl AgentViewLayout {
|
|
/// Compute layout from screen area, appearance config, prompt height,
|
|
/// todo pane height, turn status height, and prompt gap.
|
|
///
|
|
/// When `todo_height` is 0, the todo pane and its gap are omitted.
|
|
/// When `turn_status_height` is 0, the turn status line is omitted.
|
|
/// When `banner_height` is 0, the reserved banner row is omitted.
|
|
/// When `follow_ups_height` is 0, the follow-up chips row is omitted; it
|
|
/// is force-suppressed on short terminals (`area.height <=
|
|
/// SHORT_TERMINAL_ROWS`) so the prompt and scrollback are never starved.
|
|
/// When `startup_warning_height` is 0, the startup warning area is omitted.
|
|
/// `prompt_gap` is 0 or 1 — controls the gap row between turn status
|
|
/// (or scrollback) and the prompt widget.
|
|
/// `timeline_width` reserves rail columns for the timeline sidebar in
|
|
/// place of the scrollbar (0 = hidden); it requires the scrollbar's
|
|
/// gutter geometry, so a disabled scrollbar forces it to 0.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn compute(
|
|
area: Rect,
|
|
layout_cfg: &LayoutConfig,
|
|
scrollbar_cfg: &ScrollbarConfig,
|
|
timeline_width: u16,
|
|
prompt_height: u16,
|
|
tasks_height: u16,
|
|
catalog_height: u16,
|
|
todo_height: u16,
|
|
queue_height: u16,
|
|
btw_height: u16,
|
|
turn_status_height: u16,
|
|
banner_height: u16,
|
|
follow_ups_height: u16,
|
|
startup_warning_height: u16,
|
|
prompt_gap: u16,
|
|
shortcuts_height: u16,
|
|
compact: bool,
|
|
) -> Self {
|
|
let outer_vpad = layout_cfg.eff_outer_vpad(compact);
|
|
let bottom_vpad = if area.height <= SHORT_TERMINAL_ROWS {
|
|
0
|
|
} else {
|
|
outer_vpad
|
|
};
|
|
let follow_ups_height = if area.height <= SHORT_TERMINAL_ROWS {
|
|
0
|
|
} else {
|
|
follow_ups_height
|
|
};
|
|
let top_vpad = outer_vpad;
|
|
let outer_block = Block::default().padding(Padding::new(
|
|
layout_cfg.eff_hpad_left(compact),
|
|
layout_cfg.eff_hpad_right(compact),
|
|
top_vpad,
|
|
bottom_vpad,
|
|
));
|
|
let inner_area = outer_block.inner(area);
|
|
let mut constraints = vec![Constraint::Length(1)];
|
|
if startup_warning_height > 0 {
|
|
constraints.push(Constraint::Length(startup_warning_height));
|
|
}
|
|
let pane_gap = if top_vpad == 0 { 0u16 } else { 1 };
|
|
if tasks_height > 0 {
|
|
constraints.push(Constraint::Length(pane_gap));
|
|
constraints.push(Constraint::Length(tasks_height));
|
|
}
|
|
if catalog_height > 0 {
|
|
constraints.push(Constraint::Length(pane_gap));
|
|
constraints.push(Constraint::Length(catalog_height));
|
|
}
|
|
if todo_height > 0 {
|
|
constraints.push(Constraint::Length(pane_gap));
|
|
constraints.push(Constraint::Length(todo_height));
|
|
}
|
|
let status_gap = if top_vpad == 0 { 0u16 } else { 1 };
|
|
constraints.push(Constraint::Length(status_gap));
|
|
constraints.push(Constraint::Min(5));
|
|
if btw_height > 0 {
|
|
constraints.push(Constraint::Length(1));
|
|
constraints.push(Constraint::Length(btw_height));
|
|
}
|
|
if queue_height > 0 {
|
|
constraints.push(Constraint::Length(1));
|
|
constraints.push(Constraint::Length(queue_height));
|
|
}
|
|
if turn_status_height > 0 {
|
|
constraints.push(Constraint::Length(1));
|
|
constraints.push(Constraint::Length(turn_status_height));
|
|
}
|
|
if banner_height > 0 {
|
|
constraints.push(Constraint::Length(1));
|
|
constraints.push(Constraint::Length(banner_height));
|
|
}
|
|
if follow_ups_height > 0 {
|
|
constraints.push(Constraint::Length(1));
|
|
constraints.push(Constraint::Length(follow_ups_height));
|
|
}
|
|
if prompt_gap > 0 {
|
|
constraints.push(Constraint::Length(prompt_gap));
|
|
}
|
|
constraints.push(Constraint::Length(prompt_height));
|
|
let shortcuts_gap = if bottom_vpad == 0 { 0u16 } else { 1 };
|
|
if shortcuts_gap > 0 {
|
|
constraints.push(Constraint::Length(shortcuts_gap));
|
|
}
|
|
constraints.push(Constraint::Length(shortcuts_height));
|
|
let chunks = Layout::vertical(constraints).split(inner_area);
|
|
let mut i = 0;
|
|
let status_bar = chunks[i];
|
|
i += 1;
|
|
let startup_warnings = if startup_warning_height > 0 {
|
|
let r = chunks[i];
|
|
i += 1;
|
|
r
|
|
} else {
|
|
Rect::default()
|
|
};
|
|
let tasks = if tasks_height > 0 {
|
|
i += 1;
|
|
let r = chunks[i];
|
|
i += 1;
|
|
r
|
|
} else {
|
|
Rect::default()
|
|
};
|
|
let catalog = if catalog_height > 0 {
|
|
i += 1;
|
|
let r = chunks[i];
|
|
i += 1;
|
|
r
|
|
} else {
|
|
Rect::default()
|
|
};
|
|
let todo = if todo_height > 0 {
|
|
i += 1;
|
|
let r = chunks[i];
|
|
i += 1;
|
|
r
|
|
} else {
|
|
Rect::default()
|
|
};
|
|
i += 1;
|
|
let scrollback = chunks[i];
|
|
i += 1;
|
|
let btw = if btw_height > 0 {
|
|
i += 1;
|
|
let r = chunks[i];
|
|
i += 1;
|
|
r
|
|
} else {
|
|
Rect::default()
|
|
};
|
|
let queue = if queue_height > 0 {
|
|
i += 1;
|
|
let r = chunks[i];
|
|
i += 1;
|
|
r
|
|
} else {
|
|
Rect::default()
|
|
};
|
|
let turn_status = if turn_status_height > 0 {
|
|
i += 1;
|
|
let r = chunks[i];
|
|
i += 1;
|
|
r
|
|
} else {
|
|
Rect::default()
|
|
};
|
|
let banner = if banner_height > 0 {
|
|
i += 1;
|
|
let r = chunks[i];
|
|
i += 1;
|
|
r
|
|
} else {
|
|
Rect::default()
|
|
};
|
|
let follow_ups = if follow_ups_height > 0 {
|
|
i += 1;
|
|
let r = chunks[i];
|
|
i += 1;
|
|
r
|
|
} else {
|
|
Rect::default()
|
|
};
|
|
if prompt_gap > 0 {
|
|
i += 1;
|
|
}
|
|
let prompt = chunks[i];
|
|
i += 1;
|
|
if shortcuts_gap > 0 {
|
|
i += 1;
|
|
}
|
|
let shortcuts = chunks[i];
|
|
let scrollbar_x = area.right().saturating_sub(scrollbar_cfg.gap_right + 1);
|
|
let timeline_width = if scrollbar_cfg.enabled {
|
|
timeline_width
|
|
} else {
|
|
0
|
|
};
|
|
let timeline_x = (scrollbar_x + 1).saturating_sub(timeline_width);
|
|
let content_end_x = if timeline_width > 0 {
|
|
timeline_x.saturating_sub(scrollbar_cfg.gap_left)
|
|
} else {
|
|
scrollbar_x.saturating_sub(scrollbar_cfg.gap_left)
|
|
};
|
|
let scrollback_right = scrollback.x + scrollback.width;
|
|
let scrollback_content = if !scrollbar_cfg.enabled || content_end_x >= scrollback_right {
|
|
scrollback
|
|
} else {
|
|
Rect {
|
|
width: content_end_x.saturating_sub(scrollback.x),
|
|
..scrollback
|
|
}
|
|
};
|
|
Self {
|
|
status_bar,
|
|
startup_warnings,
|
|
tasks,
|
|
catalog,
|
|
scrollback,
|
|
todo,
|
|
queue,
|
|
btw,
|
|
turn_status,
|
|
banner,
|
|
follow_ups,
|
|
prompt,
|
|
shortcuts,
|
|
scrollback_content,
|
|
scrollbar_x,
|
|
timeline_x,
|
|
timeline_width,
|
|
}
|
|
}
|
|
/// Inner area width (for prompt height computation before full layout).
|
|
///
|
|
/// This computes just the inner width without the full layout split,
|
|
/// since prompt height is needed as input to `compute()`.
|
|
pub fn inner_width(area: Rect, layout_cfg: &LayoutConfig, compact: bool) -> u16 {
|
|
let vpad = layout_cfg.eff_outer_vpad(compact);
|
|
let outer_block = Block::default().padding(Padding::new(
|
|
layout_cfg.eff_hpad_left(compact),
|
|
layout_cfg.eff_hpad_right(compact),
|
|
vpad,
|
|
vpad,
|
|
));
|
|
outer_block.inner(area).width
|
|
}
|
|
/// Convert to PaneAreas for mouse hit-testing.
|
|
pub fn pane_areas(&self) -> PaneAreas {
|
|
PaneAreas {
|
|
scrollback: self.scrollback,
|
|
todo: self.todo,
|
|
queue: self.queue,
|
|
prompt: self.prompt,
|
|
tasks: self.tasks,
|
|
catalog: self.catalog,
|
|
}
|
|
}
|
|
}
|
|
/// Fill the screen area with base background and outer padding.
|
|
pub fn fill_background(
|
|
buf: &mut Buffer,
|
|
area: Rect,
|
|
layout_cfg: &LayoutConfig,
|
|
compact: bool,
|
|
theme: &Theme,
|
|
) {
|
|
buf.set_style(area, Style::default().bg(theme.bg_base));
|
|
let vpad = layout_cfg.eff_outer_vpad(compact);
|
|
let outer_styled = Block::default()
|
|
.padding(Padding::new(
|
|
layout_cfg.eff_hpad_left(compact),
|
|
layout_cfg.eff_hpad_right(compact),
|
|
vpad,
|
|
vpad,
|
|
))
|
|
.style(Style::default().bg(theme.bg_base));
|
|
outer_styled.render(area, buf);
|
|
}
|
|
/// Render follow-up suggestion chips into a single row, returning the
|
|
/// clickable rect of each rendered chip.
|
|
///
|
|
/// Chips render left-to-right and rendering STOPS at the first chip that does
|
|
/// not fit the row width — the result is a rendered prefix of `suggestions`
|
|
/// (index-aligned), not a filtered subset. A transient, mouse-clickable
|
|
/// affordance above the prompt — the same row slot family as the plugin CTA.
|
|
/// Suggestion text is server-controlled and already sanitized at ingestion;
|
|
/// here it is additionally length-clamped per chip and written through
|
|
/// `set_span_safe`, so a label can neither overflow the row nor inject
|
|
/// terminal escape sequences.
|
|
///
|
|
/// `hovered` highlights the chip under the mouse (`theme.bg_hover` + primary text).
|
|
pub(crate) fn render_follow_ups(
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
theme: &Theme,
|
|
suggestions: &[String],
|
|
hovered: Option<usize>,
|
|
) -> Vec<Rect> {
|
|
use crate::render::line_utils::truncate_str;
|
|
use unicode_width::UnicodeWidthStr;
|
|
let mut rects = Vec::new();
|
|
if area.height == 0 || area.width == 0 {
|
|
return rects;
|
|
}
|
|
for col in 0..area.width {
|
|
if let Some(cell) = buf.cell_mut((area.x + col, area.y)) {
|
|
cell.set_char(' ');
|
|
cell.fg = theme.text_secondary;
|
|
cell.bg = theme.bg_base;
|
|
}
|
|
}
|
|
const MAX_CHIP_LABEL: usize = 48;
|
|
let chip_style = Style::default().fg(theme.link_fg);
|
|
let hover_style = Style::default().fg(theme.text_primary).bg(theme.bg_hover);
|
|
let row_end = area.x + area.width;
|
|
let mut x = area.x;
|
|
for (i, label) in suggestions.iter().enumerate() {
|
|
let label = truncate_str(label, MAX_CHIP_LABEL);
|
|
let chip = format!("[ {label} ]");
|
|
let chip_w = chip.width() as u16;
|
|
if x >= row_end || chip_w > row_end - x {
|
|
break;
|
|
}
|
|
let style = if hovered == Some(i) {
|
|
hover_style
|
|
} else {
|
|
chip_style
|
|
};
|
|
buf.set_span_safe(x, area.y, &Span::styled(chip, style), chip_w);
|
|
rects.push(Rect::new(x, area.y, chip_w, 1));
|
|
x = x.saturating_add(chip_w).saturating_add(1);
|
|
}
|
|
rects
|
|
}
|
|
/// Render hover box around a scrollback entry (dimmed selection border).
|
|
///
|
|
/// Skipped if the hovered entry is also the selected entry (selection box wins).
|
|
pub fn render_entry_hover(
|
|
buf: &mut Buffer,
|
|
scrollback_area: Rect,
|
|
scrollback: &ScrollbackState,
|
|
hovered_entry: Option<usize>,
|
|
theme: &Theme,
|
|
) {
|
|
let Some(hover_idx) = hovered_entry else {
|
|
return;
|
|
};
|
|
if scrollback.selected() == Some(hover_idx) {
|
|
return;
|
|
}
|
|
let split_mode = scrollback
|
|
.appearance()
|
|
.scrollback
|
|
.display
|
|
.group_selection_split;
|
|
let group = scrollback.group_range_of(hover_idx, split_mode);
|
|
if group.len() <= 1 {
|
|
if let Some((area, top_clipped, bottom_clipped)) =
|
|
scrollback.entry_screen_area(hover_idx, scrollback_area)
|
|
{
|
|
SelectionBox::new(area, Style::default().fg(theme.hover_border))
|
|
.with_top_clipped(top_clipped)
|
|
.with_bottom_clipped(bottom_clipped)
|
|
.render(buf);
|
|
}
|
|
} else {
|
|
let first_area = scrollback.entry_screen_area(group.start, scrollback_area);
|
|
let last_area = scrollback.entry_screen_area(group.end - 1, scrollback_area);
|
|
if let (
|
|
Some((first_rect, first_top_clipped, _)),
|
|
Some((last_rect, _, last_bottom_clipped)),
|
|
) = (first_area, last_area)
|
|
{
|
|
let y_start = first_rect.y;
|
|
let y_end = last_rect.y + last_rect.height;
|
|
let combined = Rect::new(first_rect.x, y_start, first_rect.width, y_end - y_start);
|
|
SelectionBox::new(combined, Style::default().fg(theme.hover_border))
|
|
.with_top_clipped(first_top_clipped)
|
|
.with_bottom_clipped(last_bottom_clipped)
|
|
.render(buf);
|
|
}
|
|
}
|
|
}
|
|
/// Render a floating popup showing hook details when hovering over
|
|
/// a collapsed tool call entry that has hooks.
|
|
pub fn render_hook_hover_popup(
|
|
buf: &mut Buffer,
|
|
scrollback_area: Rect,
|
|
scrollback: &ScrollbackState,
|
|
hovered_entry: Option<usize>,
|
|
mouse_pos: (u16, u16),
|
|
theme: &Theme,
|
|
) {
|
|
let Some(hover_idx) = hovered_entry else {
|
|
return;
|
|
};
|
|
let Some(entry) = scrollback.get(hover_idx) else {
|
|
return;
|
|
};
|
|
if entry.display_mode != crate::scrollback::types::DisplayMode::Collapsed {
|
|
return;
|
|
}
|
|
let Some(ref hd) = entry.hook_data else {
|
|
return;
|
|
};
|
|
if !hd.has_content() {
|
|
return;
|
|
}
|
|
use crate::scrollback::blocks::tool::hook::render_hooks_for_mode;
|
|
let mode = crate::scrollback::types::DisplayMode::Expanded;
|
|
let mut lines = Vec::new();
|
|
let pre = render_hooks_for_mode("pre_tool_use", &hd.pre_hooks, mode);
|
|
let post = render_hooks_for_mode("post_tool_use", &hd.post_hooks, mode);
|
|
lines.extend(pre);
|
|
lines.extend(post);
|
|
for (event_name, runs) in &hd.lifecycle {
|
|
lines.extend(render_hooks_for_mode(event_name, runs, mode));
|
|
}
|
|
if lines.is_empty() {
|
|
return;
|
|
}
|
|
let Some((entry_area, _, _)) = scrollback.entry_screen_area(hover_idx, scrollback_area) else {
|
|
return;
|
|
};
|
|
let (mouse_col, mouse_row) = mouse_pos;
|
|
if mouse_row < entry_area.y || mouse_row >= entry_area.y + entry_area.height {
|
|
return;
|
|
}
|
|
let badge_row = entry_area.y;
|
|
let row_start = scrollback_area.x;
|
|
let row_end = scrollback_area.x + scrollback_area.width;
|
|
let row_text: String = (row_start..row_end)
|
|
.filter_map(|col| {
|
|
buf.cell(ratatui::layout::Position::new(col, badge_row))
|
|
.map(|c| c.symbol().to_string())
|
|
})
|
|
.collect();
|
|
let Some(badge_start_in_text) = row_text.find("[hooks:") else {
|
|
return;
|
|
};
|
|
let badge_end_in_text = row_text[badge_start_in_text..]
|
|
.find(']')
|
|
.map(|i| badge_start_in_text + i + 1)
|
|
.unwrap_or(row_text.len());
|
|
let badge_start_col = row_start + badge_start_in_text as u16;
|
|
let badge_end_col = row_start + badge_end_in_text as u16;
|
|
if mouse_row != badge_row || mouse_col < badge_start_col || mouse_col >= badge_end_col {
|
|
return;
|
|
}
|
|
let buf_height = buf.area.height;
|
|
let buf_width = buf.area.width;
|
|
let popup_height = (lines.len() as u16 + 2).min(15).min(buf_height);
|
|
let popup_width = scrollback_area
|
|
.width
|
|
.saturating_sub(8)
|
|
.max(40)
|
|
.min(buf_width);
|
|
let popup_y = if entry_area.y + entry_area.height + popup_height
|
|
<= scrollback_area.y + scrollback_area.height
|
|
{
|
|
entry_area.y + entry_area.height
|
|
} else {
|
|
entry_area.y.saturating_sub(popup_height)
|
|
};
|
|
let popup_x = entry_area.x + 4;
|
|
let popup_area = Rect::new(
|
|
popup_x.min(scrollback_area.x + scrollback_area.width.saturating_sub(popup_width)),
|
|
popup_y,
|
|
popup_width,
|
|
popup_height.min(buf_height.saturating_sub(popup_y)),
|
|
);
|
|
let bg = theme.bg_base;
|
|
let clear_style = ratatui::style::Style::default().bg(bg);
|
|
for y in popup_area.y..popup_area.y + popup_area.height {
|
|
for x in popup_area.x..popup_area.x + popup_area.width {
|
|
if let Some(cell) = buf.cell_mut(ratatui::layout::Position::new(x, y)) {
|
|
cell.reset();
|
|
cell.set_style(clear_style);
|
|
}
|
|
}
|
|
}
|
|
let border_style = ratatui::style::Style::default().fg(theme.gray);
|
|
let block = ratatui::widgets::Block::default()
|
|
.borders(ratatui::widgets::Borders::ALL)
|
|
.border_type(ratatui::widgets::BorderType::Rounded)
|
|
.border_style(border_style)
|
|
.style(ratatui::style::Style::default().bg(bg));
|
|
let inner = block.inner(popup_area);
|
|
ratatui::widgets::Widget::render(block, popup_area, buf);
|
|
for (i, line) in lines.iter().enumerate() {
|
|
let y = inner.y + i as u16;
|
|
if y >= inner.y + inner.height {
|
|
break;
|
|
}
|
|
buf.set_line_safe(inner.x, y, &line.content, inner.width);
|
|
}
|
|
}
|
|
/// Selection/hover chrome for a side pane (todo / queue / tasks). Focused panes get a dismiss control.
|
|
pub fn render_todo_chrome(
|
|
buf: &mut Buffer,
|
|
todo_area: Rect,
|
|
layout_cfg: &LayoutConfig,
|
|
focused: bool,
|
|
hovered: bool,
|
|
close_hovered: bool,
|
|
theme: &Theme,
|
|
) -> Option<SelectionBox> {
|
|
render_todo_chrome_with_close_label(
|
|
buf,
|
|
todo_area,
|
|
layout_cfg,
|
|
focused,
|
|
hovered,
|
|
close_hovered,
|
|
theme,
|
|
None,
|
|
)
|
|
}
|
|
/// Like [`render_todo_chrome`], with optional close label (queue uses `[close]`).
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn render_todo_chrome_with_close_label(
|
|
buf: &mut Buffer,
|
|
todo_area: Rect,
|
|
layout_cfg: &LayoutConfig,
|
|
focused: bool,
|
|
hovered: bool,
|
|
close_hovered: bool,
|
|
theme: &Theme,
|
|
close_label: Option<&'static str>,
|
|
) -> Option<SelectionBox> {
|
|
if todo_area.area() == 0 {
|
|
return None;
|
|
}
|
|
let color = if focused {
|
|
theme.selection_border
|
|
} else if hovered {
|
|
theme.hover_border
|
|
} else {
|
|
return None;
|
|
};
|
|
let layout = HorizontalLayout::new(todo_area, layout_cfg);
|
|
let mut sel = SelectionBox::new(layout.selection_area(), Style::default().fg(color))
|
|
.with_closable(focused, close_hovered);
|
|
if focused && let Some(label) = close_label {
|
|
sel = sel.with_close_label(Some(label));
|
|
}
|
|
sel.render(buf);
|
|
Some(sel)
|
|
}
|
|
/// Render the scrollbar track and thumb.
|
|
///
|
|
/// When `is_following` is true, the scrollbar thumb is dimmed to indicate
|
|
/// the viewport is locked to the bottom. This makes G / follow state
|
|
/// immediately visible in the scrollbar.
|
|
pub fn render_scrollbar(
|
|
buf: &mut Buffer,
|
|
scrollback_area: Rect,
|
|
scrollbar_x: u16,
|
|
scrollbar_cfg: &ScrollbarConfig,
|
|
scroll_info: Option<crate::scrollback::selection::ScrollInfo>,
|
|
is_following: bool,
|
|
theme: &Theme,
|
|
) {
|
|
if scrollbar_cfg.enabled
|
|
&& let Some(scroll_info) = scroll_info
|
|
{
|
|
let scrollbar_area = Rect {
|
|
x: scrollbar_x,
|
|
y: scrollback_area.y,
|
|
width: 1,
|
|
height: scrollback_area.height,
|
|
};
|
|
let scrollbar_bg = scrollbar_cfg.scrollbar_bg.unwrap_or(theme.scrollbar_bg);
|
|
let scrollbar_fg = scrollbar_cfg.scrollbar_fg.unwrap_or(theme.scrollbar_fg);
|
|
let thumb_fg = if is_following {
|
|
crate::render::color::blend_color(scrollbar_bg, scrollbar_fg, 0.4)
|
|
.unwrap_or(scrollbar_fg)
|
|
} else {
|
|
scrollbar_fg
|
|
};
|
|
let track_style = Style::default().bg(scrollbar_bg);
|
|
let thumb_style = Style::default().fg(thumb_fg).bg(scrollbar_bg);
|
|
let scale = if scroll_info.total_height > u16::MAX as usize {
|
|
(scroll_info.total_height / u16::MAX as usize) + 1
|
|
} else {
|
|
1
|
|
};
|
|
let scaled_total = (scroll_info.total_height / scale) as u16;
|
|
let scaled_offset = (scroll_info.scroll_offset / scale) as u16;
|
|
render_scrollbar_styled(
|
|
buf,
|
|
Some(scrollbar_area),
|
|
scaled_total,
|
|
scroll_info.viewport_height,
|
|
scaled_offset,
|
|
track_style,
|
|
thumb_style,
|
|
);
|
|
}
|
|
}
|
|
use crate::appearance::TodoBadgeFormat;
|
|
/// Build the todo badge as styled spans (without rendering).
|
|
///
|
|
/// Returns `None` if there are no items to display.
|
|
pub fn render_todo_badge_spans(
|
|
counts: &super::todo_pane::TodoCounts,
|
|
hovered: bool,
|
|
flash: bool,
|
|
format: TodoBadgeFormat,
|
|
theme: &Theme,
|
|
) -> Option<Vec<Span<'static>>> {
|
|
use ratatui::style::Modifier;
|
|
if counts.total() == 0 {
|
|
return None;
|
|
}
|
|
let highlighted = hovered || flash;
|
|
let count_style = if highlighted {
|
|
Style::default()
|
|
.fg(theme.text_primary)
|
|
.bg(theme.bg_base)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::default().fg(theme.text_secondary).bg(theme.bg_base)
|
|
};
|
|
let dim_style = if highlighted {
|
|
Style::default().fg(theme.text_secondary).bg(theme.bg_base)
|
|
} else {
|
|
Style::default().fg(theme.gray_dim).bg(theme.bg_base)
|
|
};
|
|
if matches!(format, TodoBadgeFormat::Default) {
|
|
let total = counts.total_excluding_cancelled();
|
|
if total == 0 {
|
|
return None;
|
|
}
|
|
return Some(vec![
|
|
Span::styled(counts.completed.to_string(), count_style),
|
|
Span::styled("/", dim_style),
|
|
Span::styled(total.to_string(), count_style),
|
|
Span::styled(
|
|
format!(" {}", crate::glyphs::check_mark()),
|
|
Style::default().fg(theme.text_secondary).bg(theme.bg_base),
|
|
),
|
|
]);
|
|
}
|
|
let icon_mod = if highlighted {
|
|
Modifier::BOLD
|
|
} else {
|
|
Modifier::empty()
|
|
};
|
|
let statuses: [(usize, &str, ratatui::style::Color); 4] = [
|
|
(counts.in_progress, "▶", theme.warning),
|
|
(counts.pending, "□", theme.text_secondary),
|
|
(
|
|
counts.completed,
|
|
crate::glyphs::check_mark(),
|
|
theme.accent_success,
|
|
),
|
|
(
|
|
counts.cancelled,
|
|
crate::glyphs::ballot_x(),
|
|
theme.accent_error,
|
|
),
|
|
];
|
|
let parts: Vec<(usize, &str, ratatui::style::Color)> =
|
|
statuses.into_iter().filter(|(n, _, _)| *n > 0).collect();
|
|
if parts.is_empty() {
|
|
return None;
|
|
}
|
|
let mut spans: Vec<Span<'static>> = Vec::with_capacity(parts.len() * 4);
|
|
for (i, (count, icon, color)) in parts.iter().enumerate() {
|
|
let icon_style = Style::default()
|
|
.fg(*color)
|
|
.bg(theme.bg_base)
|
|
.add_modifier(icon_mod);
|
|
let num_style = match format {
|
|
TodoBadgeFormat::Default => {
|
|
let mut s = Style::default().fg(*color).bg(theme.bg_base);
|
|
if highlighted {
|
|
s = s.add_modifier(Modifier::BOLD);
|
|
}
|
|
s
|
|
}
|
|
_ => count_style,
|
|
};
|
|
match format {
|
|
TodoBadgeFormat::Default => {
|
|
if i > 0 {
|
|
spans.push(Span::styled(" ", dim_style));
|
|
}
|
|
spans.push(Span::styled(format!("{count}"), num_style));
|
|
}
|
|
TodoBadgeFormat::Colon => {
|
|
if i > 0 {
|
|
spans.push(Span::styled(" ", dim_style));
|
|
}
|
|
spans.push(Span::styled(*icon, icon_style));
|
|
spans.push(Span::styled(":", dim_style));
|
|
spans.push(Span::styled(format!("{count}"), num_style));
|
|
}
|
|
TodoBadgeFormat::Comma => {
|
|
if i > 0 {
|
|
spans.push(Span::styled(", ", dim_style));
|
|
}
|
|
spans.push(Span::styled(format!("{count}"), num_style));
|
|
spans.push(Span::styled(" ", dim_style));
|
|
spans.push(Span::styled(*icon, icon_style));
|
|
}
|
|
}
|
|
}
|
|
spans.push(Span::styled(
|
|
format!(" {}", crate::glyphs::check_mark()),
|
|
Style::default().fg(theme.text_secondary).bg(theme.bg_base),
|
|
));
|
|
Some(spans)
|
|
}
|
|
/// Space:prompt hint — shared across multiple scrollback hint branches.
|
|
fn space_prompt_hint() -> HintItem {
|
|
use crate::input::key::KeyShortcut;
|
|
use crossterm::event::{KeyCode, KeyModifiers};
|
|
HintItem {
|
|
keys: vec![KeyShortcut::new(KeyCode::Char(' '), KeyModifiers::NONE)],
|
|
label: "prompt".into(),
|
|
custom_display: Some("Space"),
|
|
description: None,
|
|
pinned: false,
|
|
}
|
|
}
|
|
/// Build the hints list for the shortcuts bar based on current state.
|
|
///
|
|
/// Each pane contributes its own hints dynamically. The registry provides
|
|
/// the key bindings; the view decides which ones are visible.
|
|
///
|
|
/// `fold_label` is the dynamic label for the fold action based on selected
|
|
/// entry state: "expand", "collapse", or "fold" (no foldable entry selected).
|
|
///
|
|
/// `group_header_label` ("expand"/"collapse") marks a selected group header;
|
|
/// it replaces the fold and Enter:open hints with a single Enter toggle hint.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_hints(
|
|
active_pane: ActivePane,
|
|
prompt: &PromptWidget,
|
|
registry: &ActionRegistry,
|
|
is_editing_queued: bool,
|
|
fold_label: Option<&'static str>,
|
|
group_header_label: Option<&'static str>,
|
|
thinking_label: &'static str,
|
|
show_done: bool,
|
|
selected_supports_copy: bool,
|
|
selected_meta_label: Option<&'static str>,
|
|
selected_supports_fullscreen: bool,
|
|
can_demote: bool,
|
|
selected_can_kill: bool,
|
|
multiline_mode: bool,
|
|
vim_mode: bool,
|
|
is_subagent_view: bool,
|
|
is_turn_running: bool,
|
|
has_queued_follow_up: bool,
|
|
selected_is_user_prompt: bool,
|
|
selected_is_agent_message: bool,
|
|
shift_enter_unavailable: bool,
|
|
scrollback_search: Option<&ScrollbackSearchState>,
|
|
) -> Vec<HintItem> {
|
|
let mut hints = match active_pane {
|
|
ActivePane::Todo => {
|
|
let mut hints = Vec::new();
|
|
hints.push(HintItem::new(
|
|
crate::key!('h'),
|
|
if show_done { "hide done" } else { "show done" },
|
|
));
|
|
hints
|
|
}
|
|
ActivePane::Queue => {
|
|
let mut hints = vec![
|
|
HintItem::new(crate::key!('x'), "delete row"),
|
|
HintItem::new(crate::key!('e'), "edit"),
|
|
HintItem::paired(crate::key!('J'), crate::key!('K'), "reorder"),
|
|
HintItem::new(crate::key!('y'), "copy"),
|
|
];
|
|
if is_turn_running && let Some(def) = registry.find(ActionId::InterjectPrompt) {
|
|
hints.push(def.hint());
|
|
}
|
|
hints
|
|
}
|
|
ActivePane::Prompt if is_editing_queued => {
|
|
let mut hints = Vec::new();
|
|
if prompt.can_send() {
|
|
hints.push(HintItem::new(crate::key!(Enter), "save"));
|
|
}
|
|
hints.push(HintItem::new(crate::key!(Esc), "cancel"));
|
|
hints
|
|
}
|
|
ActivePane::Prompt if prompt.history_search.is_active() => {
|
|
use crate::input::key::KeyShortcut;
|
|
use crossterm::event::KeyCode;
|
|
vec![
|
|
HintItem::paired(
|
|
KeyShortcut::key(KeyCode::Up),
|
|
KeyShortcut::key(KeyCode::Down),
|
|
"nav",
|
|
),
|
|
HintItem::paired(
|
|
KeyShortcut::key(KeyCode::PageUp),
|
|
KeyShortcut::key(KeyCode::PageDown),
|
|
"page",
|
|
),
|
|
HintItem::new(KeyShortcut::key(KeyCode::Enter), "select"),
|
|
HintItem::new(KeyShortcut::key(KeyCode::Esc), "cancel"),
|
|
]
|
|
}
|
|
ActivePane::Prompt => {
|
|
let mut hints = Vec::new();
|
|
let newline_key = if shift_enter_unavailable {
|
|
crate::key!(Enter, ALT)
|
|
} else {
|
|
crate::key!(Enter, SHIFT)
|
|
};
|
|
let submit_label = if is_turn_running { "queue" } else { "send" };
|
|
if let Some(key) = registry.key_for(ActionId::SendPrompt) {
|
|
if prompt.paste_element_at_cursor().is_some() {
|
|
hints.push(HintItem::new(key, "expand"));
|
|
} else if multiline_mode && prompt.can_send() {
|
|
hints.push(HintItem::new(newline_key, submit_label));
|
|
} else if prompt.can_send() {
|
|
hints.push(HintItem::new(key, submit_label));
|
|
} else if is_turn_running && has_queued_follow_up {
|
|
hints.push(HintItem::new(key, "send now"));
|
|
}
|
|
}
|
|
if shift_enter_unavailable && !multiline_mode && prompt.can_send() {
|
|
hints.push(HintItem::new(crate::key!(Enter, ALT), "newline"));
|
|
}
|
|
if prompt.file_ref_near_cursor() {
|
|
hints.push(HintItem::new(crate::key!(':'), "lines"));
|
|
}
|
|
if prompt.prompt_suggestion_visible() {
|
|
hints.push(
|
|
HintItem::paired(crate::key!(Tab), crate::key!(Right), "accept suggestion")
|
|
.pinned(),
|
|
);
|
|
}
|
|
hints.push(HintItem::new(crate::key!(BackTab), "mode"));
|
|
for def in registry.hints(&[When::PromptFocused, When::AgentScreen, When::Always]) {
|
|
if def.id == ActionId::SendPrompt
|
|
|| def.id == ActionId::CommandPalette
|
|
|| def.id == ActionId::Quit
|
|
{
|
|
continue;
|
|
}
|
|
hints.push(def.hint());
|
|
}
|
|
hints
|
|
}
|
|
ActivePane::Tasks => {
|
|
let mut hints = Vec::new();
|
|
if selected_supports_fullscreen {
|
|
hints.push(HintItem::new(crate::key!(Enter), "view"));
|
|
}
|
|
if selected_supports_copy {
|
|
hints.push(HintItem::new(crate::key!('y'), "copy output"));
|
|
}
|
|
if selected_can_kill {
|
|
hints.push(HintItem::new(crate::key!('x'), "kill"));
|
|
}
|
|
hints.push(HintItem::new(
|
|
crate::key!('h'),
|
|
if show_done { "hide done" } else { "show done" },
|
|
));
|
|
hints
|
|
}
|
|
ActivePane::Catalog => vec![],
|
|
ActivePane::Scrollback if scrollback_search.is_some() => {
|
|
let mut hints = Vec::new();
|
|
if vim_mode {
|
|
if scrollback_search.is_some_and(|s| s.is_composing()) {
|
|
hints.push(HintItem::new(crate::key!(Enter), "go"));
|
|
} else {
|
|
hints.push(HintItem::paired(
|
|
crate::key!('n'),
|
|
crate::key!('N'),
|
|
"next/prev",
|
|
));
|
|
}
|
|
} else {
|
|
use crate::input::key::KeyShortcut;
|
|
use crossterm::event::KeyCode;
|
|
hints.push(HintItem::paired(
|
|
KeyShortcut::key(KeyCode::Down),
|
|
KeyShortcut::key(KeyCode::Up),
|
|
"next/prev",
|
|
));
|
|
}
|
|
hints.push(HintItem::new(crate::key!(Esc), "cancel"));
|
|
hints
|
|
}
|
|
ActivePane::Scrollback => {
|
|
let mut hints = Vec::new();
|
|
let nothing_special = !selected_is_agent_message
|
|
&& !selected_is_user_prompt
|
|
&& fold_label.is_none()
|
|
&& group_header_label.is_none()
|
|
&& !selected_supports_fullscreen;
|
|
if nothing_special {
|
|
hints.push(space_prompt_hint());
|
|
}
|
|
if selected_is_agent_message {
|
|
if vim_mode
|
|
&& selected_supports_copy
|
|
&& let Some(key) = registry.key_for(ActionId::CopyBlockContent)
|
|
{
|
|
hints.push(HintItem::new(key, "copy"));
|
|
}
|
|
hints.push(space_prompt_hint());
|
|
}
|
|
if selected_is_user_prompt {
|
|
let user_collapsed = fold_label == Some("expand");
|
|
if user_collapsed {
|
|
let key = registry
|
|
.key_for_mode(ActionId::ToggleFold, vim_mode)
|
|
.or_else(|| registry.key_for_mode(ActionId::Expand, vim_mode));
|
|
if let Some(key) = key {
|
|
hints.push(HintItem::new(key, "expand"));
|
|
}
|
|
}
|
|
if let Some(key) = registry.key_for(ActionId::ExpandAllThinking) {
|
|
hints.push(HintItem::new(key, thinking_label));
|
|
}
|
|
if !user_collapsed {
|
|
hints.push(space_prompt_hint());
|
|
}
|
|
}
|
|
let user_collapsed_already_pushed =
|
|
selected_is_user_prompt && fold_label == Some("expand");
|
|
if let Some(label) = group_header_label {
|
|
if let Some(key) = registry.key_for(ActionId::OpenBlockViewer) {
|
|
hints.push(HintItem::new(key, label));
|
|
}
|
|
} else if !user_collapsed_already_pushed && let Some(label) = fold_label {
|
|
let directional = if label == "expand" {
|
|
ActionId::Expand
|
|
} else {
|
|
ActionId::Collapse
|
|
};
|
|
let key = registry
|
|
.key_for_mode(ActionId::ToggleFold, vim_mode)
|
|
.or_else(|| registry.key_for_mode(directional, vim_mode));
|
|
if let Some(key) = key {
|
|
hints.push(HintItem::new(key, label));
|
|
}
|
|
}
|
|
if group_header_label.is_none()
|
|
&& selected_supports_fullscreen
|
|
&& let Some(key) = registry.key_for(ActionId::OpenBlockViewer)
|
|
{
|
|
hints.push(HintItem::new(key, "open"));
|
|
}
|
|
if vim_mode
|
|
&& let (Some(j), Some(k)) = (
|
|
registry.key_for(ActionId::SelectNext),
|
|
registry.key_for(ActionId::SelectPrev),
|
|
)
|
|
{
|
|
hints.push(HintItem::paired(j, k, "nav").pinned());
|
|
}
|
|
if vim_mode
|
|
&& let (Some(h), Some(l)) = (
|
|
registry.key_for(ActionId::PrevTurn),
|
|
registry.key_for(ActionId::NextTurn),
|
|
)
|
|
{
|
|
let mut hint = HintItem::paired(l, h, "turn").pinned();
|
|
hint.custom_display = Some("Shift+l/h");
|
|
hints.push(hint);
|
|
}
|
|
if !selected_is_user_prompt
|
|
&& let Some(key) = registry.key_for(ActionId::ExpandAllThinking)
|
|
{
|
|
hints.push(HintItem::new(key, thinking_label));
|
|
}
|
|
if vim_mode
|
|
&& let (Some(g), Some(bg)) = (
|
|
registry.key_for(ActionId::GotoTop),
|
|
registry.key_for(ActionId::GotoBottom),
|
|
)
|
|
{
|
|
hints.push(HintItem::paired(g, bg, "top/btm"));
|
|
}
|
|
if vim_mode
|
|
&& !selected_is_agent_message
|
|
&& selected_supports_copy
|
|
&& let Some(key) = registry.key_for(ActionId::CopyBlockContent)
|
|
{
|
|
hints.push(HintItem::new(key, "copy"));
|
|
}
|
|
if vim_mode
|
|
&& let Some(label) = selected_meta_label
|
|
&& let Some(key) = registry.key_for(ActionId::CopyBlockMeta)
|
|
{
|
|
hints.push(HintItem::new(key, label));
|
|
}
|
|
if selected_can_kill {
|
|
hints.push(HintItem::new(crate::key!('x'), "kill"));
|
|
}
|
|
if is_subagent_view {
|
|
hints.push(HintItem::paired(crate::key!('q'), crate::key!(Esc), "back"));
|
|
}
|
|
hints
|
|
}
|
|
};
|
|
if is_turn_running && let Some(def) = registry.find(ActionId::CancelTurn) {
|
|
hints.push(def.hint());
|
|
}
|
|
let has_composer_payload = !prompt.text().trim().is_empty() || is_editing_queued;
|
|
if matches!(active_pane, ActivePane::Prompt)
|
|
&& ActionRegistry::interjection_possible(is_turn_running, has_composer_payload)
|
|
&& let Some(def) = registry.find(ActionId::InterjectPrompt)
|
|
{
|
|
hints.push(def.hint());
|
|
}
|
|
if can_demote {
|
|
hints.push(HintItem::new(crate::key!('g', CONTROL), "send to bg"));
|
|
}
|
|
hints
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::actions::ActionRegistry;
|
|
/// Convenience: build hints for the Scrollback pane with sensible defaults.
|
|
/// Override only what each test cares about.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn scrollback_hints(
|
|
registry: &ActionRegistry,
|
|
fold_label: Option<&'static str>,
|
|
selected_supports_copy: bool,
|
|
selected_supports_fullscreen: bool,
|
|
selected_is_user_prompt: bool,
|
|
selected_is_agent_message: bool,
|
|
) -> Vec<HintItem> {
|
|
scrollback_hints_with_vim_mode(
|
|
registry,
|
|
fold_label,
|
|
selected_supports_copy,
|
|
selected_supports_fullscreen,
|
|
selected_is_user_prompt,
|
|
selected_is_agent_message,
|
|
true,
|
|
)
|
|
}
|
|
fn scrollback_hints_with_vim_mode(
|
|
registry: &ActionRegistry,
|
|
fold_label: Option<&'static str>,
|
|
selected_supports_copy: bool,
|
|
selected_supports_fullscreen: bool,
|
|
selected_is_user_prompt: bool,
|
|
selected_is_agent_message: bool,
|
|
vim_mode: bool,
|
|
) -> Vec<HintItem> {
|
|
build_hints(
|
|
ActivePane::Scrollback,
|
|
&PromptWidget::default(),
|
|
registry,
|
|
false,
|
|
fold_label,
|
|
None,
|
|
"expand thinking",
|
|
false,
|
|
selected_supports_copy,
|
|
None,
|
|
selected_supports_fullscreen,
|
|
false,
|
|
false,
|
|
false,
|
|
vim_mode,
|
|
false,
|
|
false,
|
|
false,
|
|
selected_is_user_prompt,
|
|
selected_is_agent_message,
|
|
false,
|
|
None,
|
|
)
|
|
}
|
|
fn first_two_labels(hints: &[HintItem]) -> Vec<&str> {
|
|
hints.iter().take(2).map(|h| h.label.as_ref()).collect()
|
|
}
|
|
#[test]
|
|
fn group_header_shows_enter_toggle_hint_instead_of_open_and_fold() {
|
|
let registry = ActionRegistry::defaults();
|
|
let hints = build_hints(
|
|
ActivePane::Scrollback,
|
|
&PromptWidget::default(),
|
|
®istry,
|
|
false,
|
|
Some("expand"),
|
|
Some("expand"),
|
|
"expand thinking",
|
|
false,
|
|
false,
|
|
None,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
None,
|
|
);
|
|
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
|
|
assert!(
|
|
!labels.contains(&"open"),
|
|
"no Enter:open hint on a group header, got {labels:?}"
|
|
);
|
|
let expand_hints: Vec<&HintItem> = hints.iter().filter(|h| h.label == "expand").collect();
|
|
assert_eq!(
|
|
expand_hints.len(),
|
|
1,
|
|
"exactly one expand hint (Enter toggle, no separate fold hint), got {labels:?}"
|
|
);
|
|
let enter_key = registry
|
|
.key_for(crate::actions::ActionId::OpenBlockViewer)
|
|
.expect("OpenBlockViewer has a default key");
|
|
assert_eq!(
|
|
expand_hints[0].keys,
|
|
vec![enter_key],
|
|
"the header expand hint must be bound to Enter (OpenBlockViewer)"
|
|
);
|
|
assert_eq!(
|
|
labels.first(),
|
|
Some(&"expand"),
|
|
"the Enter toggle takes the first compact slot, got {labels:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn scrollback_user_prompt_collapsed_hoists_expand_then_thinking() {
|
|
let registry = ActionRegistry::defaults();
|
|
let hints = scrollback_hints(®istry, Some("expand"), false, false, true, false);
|
|
assert_eq!(first_two_labels(&hints), vec!["expand", "expand thinking"]);
|
|
}
|
|
#[test]
|
|
fn scrollback_user_prompt_expanded_hoists_thinking_then_space() {
|
|
let registry = ActionRegistry::defaults();
|
|
let hints = scrollback_hints(®istry, Some("collapse"), false, false, true, false);
|
|
assert_eq!(first_two_labels(&hints), vec!["expand thinking", "prompt"]);
|
|
}
|
|
#[test]
|
|
fn scrollback_agent_message_hoists_copy_then_space() {
|
|
let registry = ActionRegistry::defaults();
|
|
let hints = scrollback_hints(®istry, None, true, false, false, true);
|
|
assert_eq!(first_two_labels(&hints), vec!["copy", "prompt"]);
|
|
}
|
|
#[test]
|
|
fn scrollback_agent_message_no_duplicate_y_copy_in_full_list() {
|
|
let registry = ActionRegistry::defaults();
|
|
let hints = scrollback_hints(®istry, None, true, false, false, true);
|
|
let copy_count = hints.iter().filter(|h| h.label == "copy").count();
|
|
assert_eq!(copy_count, 1, "copy should appear exactly once");
|
|
}
|
|
#[test]
|
|
fn scrollback_default_block_shows_prompt_first() {
|
|
let registry = ActionRegistry::defaults();
|
|
let hints = scrollback_hints(®istry, None, false, false, false, false);
|
|
assert_eq!(first_two_labels(&hints), vec!["prompt", "nav"]);
|
|
}
|
|
#[test]
|
|
fn scrollback_foldable_non_user_block_hoists_fold_then_open() {
|
|
let registry = ActionRegistry::defaults();
|
|
let hints = scrollback_hints(®istry, Some("fold"), false, true, false, false);
|
|
assert_eq!(first_two_labels(&hints), vec!["fold", "open"]);
|
|
}
|
|
#[test]
|
|
fn scrollback_vim_mode_on_shows_nav_and_turn_hints() {
|
|
let registry = ActionRegistry::defaults();
|
|
let hints =
|
|
scrollback_hints_with_vim_mode(®istry, None, false, false, false, false, true);
|
|
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
|
|
assert!(
|
|
labels.contains(&"nav"),
|
|
"vim mode should show j/k nav hint; got {labels:?}"
|
|
);
|
|
assert!(
|
|
labels.contains(&"turn"),
|
|
"vim mode should show Shift+l/h turn hint; got {labels:?}"
|
|
);
|
|
assert!(
|
|
labels.contains(&"top/btm"),
|
|
"vim mode should show g/G top/btm hint; got {labels:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn scrollback_vim_mode_off_hides_nav_turn_and_topbtm() {
|
|
let registry = ActionRegistry::defaults();
|
|
let hints =
|
|
scrollback_hints_with_vim_mode(®istry, None, false, false, false, false, false);
|
|
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
|
|
assert!(
|
|
!labels.contains(&"nav"),
|
|
"vim-off must hide j/k nav hint; got {labels:?}"
|
|
);
|
|
assert!(
|
|
!labels.contains(&"turn"),
|
|
"vim-off must hide Shift+l/h turn hint; got {labels:?}"
|
|
);
|
|
assert!(
|
|
!labels.contains(&"top/btm"),
|
|
"vim-off must hide g/G top/btm hint; got {labels:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn scrollback_vim_mode_off_hides_y_copy_on_agent_message() {
|
|
let registry = ActionRegistry::defaults();
|
|
let hints =
|
|
scrollback_hints_with_vim_mode(®istry, None, true, false, false, true, false);
|
|
assert!(
|
|
!hints.iter().any(|h| h.label == "copy"),
|
|
"vim-off must hide y:copy hint on agent message"
|
|
);
|
|
}
|
|
#[test]
|
|
fn scrollback_never_shows_rewind_hint_on_user_prompt() {
|
|
let registry = ActionRegistry::defaults();
|
|
for vim_mode in [true, false] {
|
|
let hints = scrollback_hints_with_vim_mode(
|
|
®istry,
|
|
Some("expand"),
|
|
false,
|
|
false,
|
|
true,
|
|
false,
|
|
vim_mode,
|
|
);
|
|
assert!(
|
|
!hints.iter().any(|h| h.label == "rewind"),
|
|
"rewind is slash-command only (vim_mode={vim_mode})"
|
|
);
|
|
}
|
|
}
|
|
/// Build scrollback hints with an open search session in the given phase.
|
|
fn scrollback_search_hints(
|
|
registry: &ActionRegistry,
|
|
vim_mode: bool,
|
|
composing: bool,
|
|
) -> Vec<HintItem> {
|
|
let mut search = ScrollbackSearchState::open();
|
|
if !composing {
|
|
search.accept();
|
|
}
|
|
build_hints(
|
|
ActivePane::Scrollback,
|
|
&PromptWidget::default(),
|
|
registry,
|
|
false,
|
|
None,
|
|
None,
|
|
"expand thinking",
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
vim_mode,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
Some(&search),
|
|
)
|
|
}
|
|
#[test]
|
|
fn scrollback_search_hint_not_in_bottom_bar() {
|
|
let registry = ActionRegistry::defaults();
|
|
for vim_mode in [true, false] {
|
|
let hints = scrollback_hints_with_vim_mode(
|
|
®istry, None, false, false, false, false, vim_mode,
|
|
);
|
|
assert!(
|
|
!hints.iter().any(|h| h.label == "search"),
|
|
"bottom bar must not advertise / search (vim_mode={vim_mode})"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn scrollback_search_composing_shows_go_and_cancel_only() {
|
|
let registry = ActionRegistry::defaults();
|
|
let labels: Vec<String> = scrollback_search_hints(®istry, true, true)
|
|
.iter()
|
|
.map(|h| h.label.to_string())
|
|
.collect();
|
|
assert!(labels.contains(&"go".to_string()), "got {labels:?}");
|
|
assert!(labels.contains(&"cancel".to_string()), "got {labels:?}");
|
|
assert!(!labels.contains(&"next/prev".to_string()), "got {labels:?}");
|
|
assert!(!labels.contains(&"nav".to_string()), "got {labels:?}");
|
|
assert!(!labels.contains(&"search".to_string()), "got {labels:?}");
|
|
}
|
|
#[test]
|
|
fn scrollback_search_browsing_shows_next_prev_and_cancel() {
|
|
let registry = ActionRegistry::defaults();
|
|
let labels: Vec<String> = scrollback_search_hints(®istry, true, false)
|
|
.iter()
|
|
.map(|h| h.label.to_string())
|
|
.collect();
|
|
assert!(labels.contains(&"next/prev".to_string()), "got {labels:?}");
|
|
assert!(labels.contains(&"cancel".to_string()), "got {labels:?}");
|
|
assert!(!labels.contains(&"go".to_string()), "got {labels:?}");
|
|
assert!(!labels.contains(&"nav".to_string()), "got {labels:?}");
|
|
}
|
|
/// The keys behind the `next/prev` hint for a search in the given phase.
|
|
fn next_prev_keys(
|
|
registry: &ActionRegistry,
|
|
vim_mode: bool,
|
|
composing: bool,
|
|
) -> Vec<crate::input::key::KeyShortcut> {
|
|
scrollback_search_hints(registry, vim_mode, composing)
|
|
.into_iter()
|
|
.find(|h| h.label == "next/prev")
|
|
.map(|h| h.keys)
|
|
.unwrap_or_default()
|
|
}
|
|
#[test]
|
|
fn scrollback_search_vim_browsing_uses_n_keys() {
|
|
use crossterm::event::KeyCode;
|
|
let registry = ActionRegistry::defaults();
|
|
let keys = next_prev_keys(®istry, true, false);
|
|
let codes: Vec<KeyCode> = keys.iter().map(|k| k.code).collect();
|
|
assert_eq!(codes, vec![KeyCode::Char('n'), KeyCode::Char('N')]);
|
|
}
|
|
#[test]
|
|
fn scrollback_search_simple_mode_uses_arrow_keys_in_both_phases() {
|
|
use crossterm::event::KeyCode;
|
|
let registry = ActionRegistry::defaults();
|
|
for composing in [true, false] {
|
|
let codes: Vec<KeyCode> = next_prev_keys(®istry, false, composing)
|
|
.iter()
|
|
.map(|k| k.code)
|
|
.collect();
|
|
assert_eq!(
|
|
codes,
|
|
vec![KeyCode::Down, KeyCode::Up],
|
|
"simple mode next/prev should be arrows (composing={composing})"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn prompt_branch_excludes_exit_session_home_hint() {
|
|
let registry = ActionRegistry::defaults();
|
|
let hints = build_hints(
|
|
ActivePane::Prompt,
|
|
&PromptWidget::default(),
|
|
®istry,
|
|
false,
|
|
None,
|
|
None,
|
|
"expand thinking",
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
None,
|
|
);
|
|
assert!(
|
|
!hints.iter().any(|h| h.label == "home"),
|
|
"ExitSession (home) must not appear in prompt-focused bar"
|
|
);
|
|
}
|
|
fn prompt_hints_with_text(
|
|
multiline_mode: bool,
|
|
shift_enter_unavailable: bool,
|
|
) -> Vec<HintItem> {
|
|
prompt_hints_with_text_and_turn(multiline_mode, shift_enter_unavailable, false)
|
|
}
|
|
fn prompt_hints_with_text_and_turn(
|
|
multiline_mode: bool,
|
|
shift_enter_unavailable: bool,
|
|
is_turn_running: bool,
|
|
) -> Vec<HintItem> {
|
|
let mut prompt = PromptWidget::default();
|
|
prompt.textarea.insert_str("hello");
|
|
let registry = ActionRegistry::defaults();
|
|
build_hints(
|
|
ActivePane::Prompt,
|
|
&prompt,
|
|
®istry,
|
|
false,
|
|
None,
|
|
None,
|
|
"expand thinking",
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
false,
|
|
multiline_mode,
|
|
true,
|
|
false,
|
|
is_turn_running,
|
|
false,
|
|
false,
|
|
false,
|
|
shift_enter_unavailable,
|
|
None,
|
|
)
|
|
}
|
|
#[test]
|
|
fn prompt_idle_submit_hint_is_send() {
|
|
let hints = prompt_hints_with_text_and_turn(false, false, false);
|
|
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
|
|
assert!(
|
|
labels.contains(&"send") && !labels.contains(&"queue"),
|
|
"idle prompt must advertise Enter:send; got {labels:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn prompt_running_submit_hint_is_queue_and_send_now() {
|
|
let hints = prompt_hints_with_text_and_turn(false, false, true);
|
|
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
|
|
assert!(
|
|
labels.contains(&"queue"),
|
|
"mid-turn follow-up must advertise Enter:queue (not send); got {labels:?}"
|
|
);
|
|
assert!(
|
|
!labels.contains(&"send"),
|
|
"mid-turn must not mislabel Enter as send; got {labels:?}"
|
|
);
|
|
assert!(
|
|
labels.contains(&"send now"),
|
|
"mid-turn with composer text must advertise the send-now (interject) chord; got {labels:?}"
|
|
);
|
|
}
|
|
/// Empty composer + mid-turn queue: bare Enter is send-now in both normal
|
|
/// and multiline modes (multiline only inserts newline when there is text).
|
|
#[test]
|
|
fn prompt_empty_mid_turn_queue_advertises_send_now_including_multiline() {
|
|
for multiline in [false, true] {
|
|
let prompt = PromptWidget::default();
|
|
let registry = ActionRegistry::defaults();
|
|
let hints = build_hints(
|
|
ActivePane::Prompt,
|
|
&prompt,
|
|
®istry,
|
|
false,
|
|
None,
|
|
None,
|
|
"expand thinking",
|
|
false,
|
|
false,
|
|
None,
|
|
false,
|
|
false,
|
|
false,
|
|
multiline,
|
|
true,
|
|
false,
|
|
true,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
None,
|
|
);
|
|
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
|
|
assert!(
|
|
labels.contains(&"send now"),
|
|
"empty composer mid-turn with queue must advertise Enter:send now \
|
|
(multiline={multiline}); got {labels:?}"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn prompt_legacy_vte_adds_alt_enter_newline_hint() {
|
|
let hints = prompt_hints_with_text(false, true);
|
|
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
|
|
assert!(
|
|
labels.contains(&"newline"),
|
|
"legacy VTE must surface an explicit Alt+Enter newline hint; \
|
|
got {labels:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn prompt_modern_terminal_no_newline_hint() {
|
|
let hints = prompt_hints_with_text(false, false);
|
|
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
|
|
assert!(
|
|
!labels.contains(&"newline"),
|
|
"modern terminals must not show the legacy-VTE newline hint \
|
|
(Shift+Enter works natively); got {labels:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn prompt_multiline_mode_no_extra_newline_hint() {
|
|
let hints = prompt_hints_with_text(true, true);
|
|
let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
|
|
assert!(
|
|
!labels.contains(&"newline"),
|
|
"multiline mode must not show the legacy-VTE newline hint \
|
|
(Enter already inserts a newline); got {labels:?}"
|
|
);
|
|
}
|
|
#[test]
|
|
fn prompt_multiline_send_hint_uses_alt_on_legacy_vte() {
|
|
use crossterm::event::KeyModifiers;
|
|
let hints = prompt_hints_with_text(true, true);
|
|
let send_hint = hints
|
|
.iter()
|
|
.find(|h| h.label == "send")
|
|
.expect("multiline mode with text must show a send hint");
|
|
let key = send_hint
|
|
.keys
|
|
.first()
|
|
.expect("send hint must have at least one key");
|
|
assert!(
|
|
key.modifiers.contains(KeyModifiers::ALT),
|
|
"legacy VTE multiline send hint must advertise Alt+Enter, \
|
|
got modifiers {:?}",
|
|
key.modifiers
|
|
);
|
|
assert!(
|
|
!key.modifiers.contains(KeyModifiers::SHIFT),
|
|
"legacy VTE multiline send hint must NOT advertise Shift+Enter, \
|
|
got modifiers {:?}",
|
|
key.modifiers
|
|
);
|
|
}
|
|
#[test]
|
|
fn prompt_multiline_send_hint_uses_shift_on_modern_terminal() {
|
|
use crossterm::event::KeyModifiers;
|
|
let hints = prompt_hints_with_text(true, false);
|
|
let send_hint = hints
|
|
.iter()
|
|
.find(|h| h.label == "send")
|
|
.expect("multiline mode with text must show a send hint");
|
|
let key = send_hint
|
|
.keys
|
|
.first()
|
|
.expect("send hint must have at least one key");
|
|
assert!(
|
|
key.modifiers.contains(KeyModifiers::SHIFT),
|
|
"modern terminal multiline send hint must advertise Shift+Enter, \
|
|
got modifiers {:?}",
|
|
key.modifiers
|
|
);
|
|
}
|
|
fn layout_with_rows(area: Rect, banner_height: u16, follow_ups_height: u16) -> AgentViewLayout {
|
|
let layout_cfg = LayoutConfig::default();
|
|
let scrollbar_cfg = ScrollbarConfig::default();
|
|
AgentViewLayout::compute(
|
|
area,
|
|
&layout_cfg,
|
|
&scrollbar_cfg,
|
|
0,
|
|
2,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
banner_height,
|
|
follow_ups_height,
|
|
0,
|
|
0,
|
|
1,
|
|
false,
|
|
)
|
|
}
|
|
/// Minimal layout with a timeline rail request — hides the cfg-dependent
|
|
/// arity of `compute` like `layout_with_rows` does.
|
|
fn layout_with_rail(
|
|
area: Rect,
|
|
timeline_width: u16,
|
|
scrollbar_cfg: &ScrollbarConfig,
|
|
) -> AgentViewLayout {
|
|
let layout_cfg = LayoutConfig::default();
|
|
AgentViewLayout::compute(
|
|
area,
|
|
&layout_cfg,
|
|
scrollbar_cfg,
|
|
timeline_width,
|
|
2,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
1,
|
|
false,
|
|
)
|
|
}
|
|
#[test]
|
|
fn timeline_rail_replaces_the_scrollbar_column() {
|
|
let area = Rect::new(0, 0, 80, 40);
|
|
let scrollbar_cfg = ScrollbarConfig::default();
|
|
let without = layout_with_rail(area, 0, &scrollbar_cfg);
|
|
let with_rail = layout_with_rail(area, 3, &scrollbar_cfg);
|
|
assert_eq!(with_rail.timeline_width, 3);
|
|
assert_eq!(with_rail.scrollbar_x, without.scrollbar_x);
|
|
assert_eq!(with_rail.timeline_x, with_rail.scrollbar_x + 1 - 3);
|
|
assert!(
|
|
with_rail.scrollback_content.x + with_rail.scrollback_content.width
|
|
<= with_rail.timeline_x,
|
|
"content (ends {}) must not overlap the rail (starts {})",
|
|
with_rail.scrollback_content.x + with_rail.scrollback_content.width,
|
|
with_rail.timeline_x,
|
|
);
|
|
assert!(
|
|
with_rail.scrollback_content.width <= without.scrollback_content.width,
|
|
"the rail never widens the content"
|
|
);
|
|
let no_scrollbar = ScrollbarConfig {
|
|
enabled: false,
|
|
..ScrollbarConfig::default()
|
|
};
|
|
let forced_off = layout_with_rail(area, 3, &no_scrollbar);
|
|
assert_eq!(forced_off.timeline_width, 0);
|
|
assert_eq!(
|
|
forced_off.scrollback_content.width, forced_off.scrollback.width,
|
|
"no carve-out without the scrollbar gutter"
|
|
);
|
|
}
|
|
/// Banner row (mode banner / ephemeral tip slot): height 1 reserves a
|
|
/// one-row rect directly above the prompt (gap row in between).
|
|
#[test]
|
|
fn banner_row_present_above_prompt() {
|
|
let area = Rect::new(0, 0, 80, 40);
|
|
let layout = layout_with_rows(area, 1, 0);
|
|
assert_eq!(layout.banner.height, 1);
|
|
assert_eq!(layout.prompt.y, layout.banner.y + 1);
|
|
assert!(layout.banner.y >= layout.scrollback.y + layout.scrollback.height);
|
|
}
|
|
#[test]
|
|
fn banner_row_absent_when_height_zero() {
|
|
let area = Rect::new(0, 0, 80, 40);
|
|
let layout = layout_with_rows(area, 0, 0);
|
|
assert_eq!(layout.banner, Rect::default());
|
|
}
|
|
fn row_text(buf: &Buffer, area: Rect) -> String {
|
|
(0..area.width)
|
|
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
|
|
.collect()
|
|
}
|
|
#[test]
|
|
fn render_follow_ups_emits_clickable_chips() {
|
|
let area = Rect::new(0, 0, 40, 1);
|
|
let mut buf = Buffer::empty(area);
|
|
let theme = Theme::current();
|
|
let suggestions = vec!["Yes".to_string(), "No".to_string()];
|
|
let rects = render_follow_ups(area, &mut buf, &theme, &suggestions, None);
|
|
assert_eq!(rects.len(), 2, "both chips fit on a wide row");
|
|
let row = row_text(&buf, area);
|
|
assert!(row.contains("Yes"), "row = {row:?}");
|
|
assert!(row.contains("No"), "row = {row:?}");
|
|
assert!(rects[0].x + rects[0].width <= rects[1].x);
|
|
assert!(rects[1].x + rects[1].width <= area.width);
|
|
}
|
|
#[test]
|
|
fn render_follow_ups_drops_chips_that_do_not_fit() {
|
|
let area = Rect::new(0, 0, 10, 1);
|
|
let mut buf = Buffer::empty(area);
|
|
let theme = Theme::current();
|
|
let suggestions = vec!["abc".to_string(), "way too long to fit here".to_string()];
|
|
let rects = render_follow_ups(area, &mut buf, &theme, &suggestions, None);
|
|
assert_eq!(rects.len(), 1, "only the first chip fits");
|
|
}
|
|
#[test]
|
|
fn render_follow_ups_zero_area_is_noop() {
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
|
|
let theme = Theme::current();
|
|
let rects = render_follow_ups(
|
|
Rect::new(0, 0, 0, 0),
|
|
&mut buf,
|
|
&theme,
|
|
&["x".to_string()],
|
|
None,
|
|
);
|
|
assert!(rects.is_empty());
|
|
}
|
|
#[test]
|
|
fn render_follow_ups_clamps_long_label() {
|
|
let area = Rect::new(0, 0, 120, 1);
|
|
let mut buf = Buffer::empty(area);
|
|
let theme = Theme::current();
|
|
let long = "a".repeat(200);
|
|
let rects = render_follow_ups(area, &mut buf, &theme, &[long], None);
|
|
assert_eq!(rects.len(), 1);
|
|
assert!(
|
|
rects[0].width <= 52,
|
|
"chip width {} not clamped",
|
|
rects[0].width
|
|
);
|
|
}
|
|
#[test]
|
|
fn render_follow_ups_applies_hover_style() {
|
|
let area = Rect::new(0, 0, 40, 1);
|
|
let mut buf = Buffer::empty(area);
|
|
let theme = Theme::current();
|
|
let suggestions = vec!["Yes".to_string(), "No".to_string()];
|
|
let rects = render_follow_ups(area, &mut buf, &theme, &suggestions, Some(0));
|
|
assert_eq!(rects.len(), 2);
|
|
let r0 = rects[0];
|
|
let cell = buf.cell((r0.x + 1, r0.y)).expect("hovered chip cell");
|
|
assert_eq!(
|
|
cell.fg, theme.text_primary,
|
|
"hovered chip should use primary text"
|
|
);
|
|
assert_eq!(
|
|
cell.bg, theme.bg_hover,
|
|
"hovered chip should use theme.bg_hover"
|
|
);
|
|
let r1 = rects[1];
|
|
let cell1 = buf.cell((r1.x + 1, r1.y)).expect("non-hovered chip cell");
|
|
assert_eq!(cell1.fg, theme.link_fg, "non-hovered chip keeps link color");
|
|
}
|
|
#[test]
|
|
fn follow_ups_row_present_above_prompt() {
|
|
let area = Rect::new(0, 0, 80, 40);
|
|
let layout = layout_with_rows(area, 0, 1);
|
|
assert_eq!(layout.follow_ups.height, 1);
|
|
assert!(layout.follow_ups.y < layout.prompt.y);
|
|
assert!(layout.follow_ups.y >= layout.scrollback.y + layout.scrollback.height);
|
|
}
|
|
#[test]
|
|
fn follow_ups_row_absent_when_height_zero() {
|
|
let area = Rect::new(0, 0, 80, 40);
|
|
let layout = layout_with_rows(area, 0, 0);
|
|
assert_eq!(layout.follow_ups, Rect::default());
|
|
}
|
|
#[test]
|
|
fn follow_ups_row_suppressed_on_short_terminal() {
|
|
let area = Rect::new(0, 0, 80, 16);
|
|
let layout = layout_with_rows(area, 0, 1);
|
|
assert_eq!(layout.follow_ups, Rect::default());
|
|
assert!(layout.scrollback.height >= 5);
|
|
}
|
|
/// Default todo badge is a `done/total` fraction: numerator = completed,
|
|
/// denominator = all tasks except cancelled.
|
|
#[test]
|
|
fn todo_badge_default_renders_done_over_total_fraction() {
|
|
let theme = Theme::current();
|
|
let counts = super::super::todo_pane::TodoCounts {
|
|
in_progress: 1,
|
|
pending: 2,
|
|
completed: 2,
|
|
cancelled: 0,
|
|
};
|
|
let spans =
|
|
render_todo_badge_spans(&counts, false, false, TodoBadgeFormat::Default, &theme)
|
|
.expect("badge renders when there are todos");
|
|
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
|
|
assert!(
|
|
text.starts_with("2/5"),
|
|
"expected a 2/5 done/total fraction, got {text:?}"
|
|
);
|
|
let with_cancelled = super::super::todo_pane::TodoCounts {
|
|
in_progress: 0,
|
|
pending: 1,
|
|
completed: 2,
|
|
cancelled: 1,
|
|
};
|
|
let spans = render_todo_badge_spans(
|
|
&with_cancelled,
|
|
false,
|
|
false,
|
|
TodoBadgeFormat::Default,
|
|
&theme,
|
|
)
|
|
.expect("badge renders");
|
|
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
|
|
assert!(
|
|
text.starts_with("2/3"),
|
|
"cancelled tasks are excluded from the total, got {text:?}"
|
|
);
|
|
}
|
|
/// No todos → no badge.
|
|
#[test]
|
|
fn todo_badge_absent_when_no_todos() {
|
|
let theme = Theme::current();
|
|
let empty = super::super::todo_pane::TodoCounts::default();
|
|
assert!(
|
|
render_todo_badge_spans(&empty, false, false, TodoBadgeFormat::Default, &theme)
|
|
.is_none()
|
|
);
|
|
}
|
|
/// All-cancelled list → no badge (denominator would be 0; nothing to track).
|
|
#[test]
|
|
fn todo_badge_absent_when_all_cancelled() {
|
|
let theme = Theme::current();
|
|
let all_cancelled = super::super::todo_pane::TodoCounts {
|
|
in_progress: 0,
|
|
pending: 0,
|
|
completed: 0,
|
|
cancelled: 3,
|
|
};
|
|
assert!(
|
|
render_todo_badge_spans(
|
|
&all_cancelled,
|
|
false,
|
|
false,
|
|
TodoBadgeFormat::Default,
|
|
&theme
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
}
|