M0: compilable skeleton — Kigi 0.1.0 fork surgery

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).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,728 @@
//! Secondary pane input: scrollback keys and search, todo/tool-usage panes,
//! background tasks, subagent catalog, and the pane-aware scroll router.
use super::{ActivePane, AgentPane, AgentView, overlay_action_to_outcome, resolve_action};
use crate::actions::{ActionId, ActionRegistry, When};
use crate::app::actions::Action;
use crate::app::app_view::InputOutcome;
use crate::key;
use crate::scrollback::ScrollbackSearchState;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
impl AgentView {
/// Scrollback-focused key handling.
///
/// When the block viewer is open, routes keys to the viewer.
/// Otherwise, uses ActionRegistry for keybinding lookup.
pub(super) fn handle_scrollback_key(
&mut self,
key: &KeyEvent,
registry: &ActionRegistry,
) -> InputOutcome {
if let Some(outcome) = self.handle_scrollback_search_key(key) {
return outcome;
}
let viewer_has_input = self
.block_viewer
.as_ref()
.is_some_and(|v| v.list_state.input_mode().is_some());
let allow_i_alt = self.vim_mode;
if !viewer_has_input
&& (matches!(key.code, KeyCode::Tab | KeyCode::Char(' '))
|| (allow_i_alt && matches!(key.code, KeyCode::Char('i'))))
{
if self.question_view.is_some() {
self.set_active_pane(AgentPane::Prompt, false);
return InputOutcome::Changed;
}
if key.code == KeyCode::Tab
&& self.tasks.overlay.visible
&& self.set_active_pane(AgentPane::Tasks, false)
{
self.tasks.overlay.focused = true;
return InputOutcome::Changed;
}
return InputOutcome::Action(Action::FocusPrompt);
}
if key!(Enter).matches(key)
&& let Some(url) = self.highlighted_link_url().map(String::from)
{
self.highlighted_link_idx = None;
return InputOutcome::Action(Action::OpenUrl(url));
}
if key!(Enter).matches(key)
&& !self.scrollback.is_selected_group_header()
&& let Some(idx) = self.scrollback.selected()
&& self
.scrollback
.entry(idx)
.is_some_and(|e| e.block.is_user_prompt())
&& self.enter_inline_edit(idx)
{
return InputOutcome::Changed;
}
if key!(Enter).matches(key)
&& !self.scrollback.is_selected_group_header()
&& let Some(idx) = self.scrollback.selected()
&& let Some(entry) = self.scrollback.entry(idx)
&& let crate::scrollback::block::RenderBlock::Subagent(ref sb) = entry.block
{
let child_sid = sb.child_session_id.clone();
if self.subagent_views.contains_key(&child_sid) {
self.open_subagent_fullscreen(child_sid);
return InputOutcome::Changed;
}
}
if self.vim_mode
&& key!('x').matches(key)
&& !self.scrollback.is_selected_group_header()
&& let Some(idx) = self.scrollback.selected()
&& let Some(entry) = self.scrollback.entry(idx)
&& let crate::scrollback::block::RenderBlock::BgTask(ref bt) = entry.block
&& self
.session
.bg_tasks
.get(&bt.task_id)
.is_some_and(|t| t.status == crate::app::agent::BgTaskStatus::Running)
{
return InputOutcome::Action(Action::KillBgTask(bt.task_id.clone()));
}
if key.code == KeyCode::Esc
&& key.modifiers.is_empty()
&& self.persistent_text_selection.take().is_some()
{
self.table_selection_geometry = None;
self.selection_created_at = None;
return InputOutcome::Changed;
}
if key.code == KeyCode::Esc
&& key.modifiers.is_empty()
&& self.highlighted_link_idx.take().is_some()
{
return InputOutcome::Changed;
}
if self.vim_mode
&& key!('/').matches(key)
&& self.no_input_overlay_pending()
&& self.btw_state.is_none()
{
if self.scrollback.is_empty() {
return InputOutcome::ActionThenForward(Action::FocusPrompt);
}
self.open_scrollback_search(None);
return InputOutcome::Changed;
}
if key!('r', CONTROL).matches(key)
&& (registry.find(ActionId::ToggleMouseCapture).is_some()
|| crate::app::mouse_reporting_toggle_enabled())
{
return InputOutcome::Action(Action::ToggleMouseCapture);
}
if let Some(outcome) =
resolve_action(registry.lookup_with_mode(key, When::ScrollbackFocused, self.vim_mode))
{
return outcome;
}
if !self.vim_mode
&& let KeyCode::Char(c) = key.code
&& (c.is_ascii_alphabetic() || c == '/')
&& (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
{
return InputOutcome::ActionThenForward(Action::FocusPrompt);
}
InputOutcome::Unchanged
}
/// Focus the scrollback pane and open an incremental search over it.
///
/// Shared by the vim `/` key and the `/find` slash command so both entry
/// points land in the same state, refocusing scrollback when `/find` is run
/// from the prompt in simple mode.
///
/// Only opens the search if the pane switch succeeds: a dirty queued-prompt
/// edit blocks the switch (showing the confirm modal) and returns false, so
/// opening search then would strand an invisible session on the prompt — the
/// search bar and key handling are gated on scrollback being focused.
///
/// `initial_query` (the `/find <word>` argument) is fed through the same
/// keystroke path so a pre-filled search behaves identically to typing the
/// word into the bar: a composing regex query with immediate highlights.
pub(crate) fn open_scrollback_search(&mut self, initial_query: Option<&str>) {
if self.set_active_pane(AgentPane::Scrollback, false) {
self.scrollback_search = Some(ScrollbackSearchState::open());
if let Some(query) = initial_query {
self.set_scrollback_search_query(query);
}
}
}
/// Step to the next (`forward`) or previous match and scroll it into view.
/// Shared by the `n`/`N` keys and the `↓`/`↑` arrows.
fn navigate_search(&mut self, forward: bool) -> Option<InputOutcome> {
if let Some(search) = self.scrollback_search.as_mut() {
if forward {
search.next();
} else {
search.prev();
}
}
self.reveal_current_search_match();
Some(InputOutcome::Changed)
}
/// Bottom scrollback rows to reserve for the search UI (divider + bar):
/// two when search is active, clamped to the rows that actually exist so a
/// very short region never pushes the bar below the scrollback rect.
pub(super) fn search_reserved_rows(scrollback_height: u16, search_active: bool) -> u16 {
if search_active {
scrollback_height.min(2)
} else {
0
}
}
/// Handle a key while the scrollback search overlay is open.
///
/// Returns `None` when search isn't open (or, while browsing, for keys that
/// should fall through to normal scrollback handling). While composing the
/// query the bar is modal and swallows other keys.
fn handle_scrollback_search_key(&mut self, key: &KeyEvent) -> Option<InputOutcome> {
let composing = self.scrollback_search.as_ref()?.is_composing();
let non_text = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
if key.code == KeyCode::Esc {
self.scrollback_search = None;
return Some(InputOutcome::Changed);
}
if key.modifiers.is_empty() {
match key.code {
KeyCode::Down => return self.navigate_search(true),
KeyCode::Up => return self.navigate_search(false),
_ => {}
}
}
if composing {
match key.code {
KeyCode::Enter => {
if self.scrollback_search.as_ref()?.query().is_empty() {
self.scrollback_search = None;
} else {
if let Some(search) = self.scrollback_search.as_mut() {
search.accept();
}
self.reveal_current_search_match();
}
Some(InputOutcome::Changed)
}
KeyCode::Backspace => {
let mut q = self.scrollback_search.as_ref()?.query().to_string();
q.pop();
self.set_scrollback_search_query(&q);
Some(InputOutcome::Changed)
}
KeyCode::Char(c) if !key.modifiers.intersects(non_text) => {
let mut q = self.scrollback_search.as_ref()?.query().to_string();
q.push(c);
self.set_scrollback_search_query(&q);
Some(InputOutcome::Changed)
}
_ => Some(InputOutcome::Unchanged),
}
} else {
match key.code {
KeyCode::Char('n') if key.modifiers.is_empty() => self.navigate_search(true),
KeyCode::Char('N') if !key.modifiers.intersects(non_text) => {
self.navigate_search(false)
}
_ => None,
}
}
}
/// Enqueue `query` for the background scan. Results (and the reveal) arrive
/// later via [`poll_scrollback_search`](Self::poll_scrollback_search); the
/// highlight updates immediately because it reads the UI-side matcher.
fn set_scrollback_search_query(&mut self, query: &str) {
if let Some(search) = self.scrollback_search.as_mut() {
search.update_query(query, &self.scrollback);
}
}
/// Poll the background search daemon for new results, revealing the freshly
/// parked match when they change. Returns `true` if the UI should redraw.
pub(crate) fn poll_scrollback_search(&mut self) -> bool {
let changed = self.scrollback_search.as_mut().is_some_and(|s| s.poll());
if changed {
self.reveal_current_search_match();
}
changed
}
/// Scroll the current search match into view via `reveal_entry_line`.
fn reveal_current_search_match(&mut self) {
let target = self
.scrollback_search
.as_ref()
.and_then(|s| s.current())
.map(|m| (m.entry_id, m.line_in_entry));
if let Some((id, line)) = target
&& let Some(idx) = self.scrollback.index_of_id(id)
{
self.scrollback.reveal_entry_line(idx, line);
}
}
/// Todo-pane-focused key handling.
///
/// Routes structural keys through the shared overlay handler, then
/// content keys through `TodoPane::handle_key`.
pub(super) fn handle_todo_key(
&mut self,
key: &KeyEvent,
_registry: &ActionRegistry,
) -> InputOutcome {
use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key};
if key!('t', CONTROL).matches(key) {
self.todo.overlay.toggle();
self.todo.on_state_change();
if !self.todo.overlay.focused {
return InputOutcome::Action(Action::FocusScrollback);
}
return InputOutcome::Changed;
}
let has_input = self.todo.list_state.input_mode().is_some();
let action = handle_overlay_key(&mut self.todo.overlay, key).or_else(|| {
if !has_input {
handle_overlay_nav_key(&mut self.todo.overlay, key)
} else {
None
}
});
if let Some(action) = action {
self.todo.on_state_change();
if !self.todo.overlay.visible || !self.todo.overlay.focused {
self.set_active_pane(AgentPane::Scrollback, false);
}
return overlay_action_to_outcome(action);
}
if self.todo.handle_key(key) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
/// Bg-task-pane-focused key handling.
pub(super) fn handle_bg_tasks_key(
&mut self,
key: &KeyEvent,
_registry: &ActionRegistry,
) -> InputOutcome {
use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key};
use crate::views::tasks_pane::TaskEntry;
if key!('b', CONTROL).matches(key) {
self.tasks.overlay.toggle();
self.tasks.on_state_change();
if !self.tasks.overlay.focused {
return InputOutcome::Action(Action::FocusScrollback);
}
return InputOutcome::Changed;
}
if self.tasks.list_state.input_mode().is_none()
&& let Some(group) = self.tasks.selected_header_group()
{
if key!(Right).matches(key) {
self.tasks.set_group_collapsed(group, false);
return InputOutcome::Changed;
}
if key!(Left).matches(key) {
self.tasks.set_group_collapsed(group, true);
return InputOutcome::Changed;
}
}
let is_open_key = self.tasks.list_state.input_mode().is_none()
&& (key!(Enter).matches(key) || key!('f', CONTROL).matches(key));
if is_open_key {
if let Some(group) = self.tasks.selected_header_group() {
self.tasks.toggle_group(group);
return InputOutcome::Changed;
}
match self.tasks.selected_entry() {
Some(TaskEntry::BgTask { task_id, .. }) => {
let task_id = task_id.clone();
if let Some(task) = self.session.bg_tasks.get(&task_id) {
let entry_id = task
.scrollback_entry_id
.unwrap_or_else(|| crate::scrollback::entry::EntryId::new(0));
let is_running = task.status == crate::app::agent::BgTaskStatus::Running;
self.block_viewer =
Some(crate::views::block_viewer::BlockViewerPane::for_bg_task(
entry_id,
&task_id,
&task.stdout,
is_running,
));
self.set_active_pane(AgentPane::Scrollback, true);
return InputOutcome::Changed;
}
}
Some(TaskEntry::Agent {
child_session_id, ..
}) => {
let child_sid = child_session_id.clone();
if self.subagent_views.contains_key(&child_sid) {
self.open_subagent_fullscreen(child_sid);
return InputOutcome::Changed;
}
}
Some(TaskEntry::Scheduled { .. }) => {}
Some(TaskEntry::Header { .. }) => {}
None => {}
}
}
if key!('x').matches(key) && self.tasks.list_state.input_mode().is_none() {
match self.tasks.selected_entry() {
Some(TaskEntry::BgTask { task_id, .. }) => {
let task_id = task_id.clone();
if self
.session
.bg_tasks
.get(&task_id)
.is_some_and(|t| t.status == crate::app::agent::BgTaskStatus::Running)
{
return InputOutcome::Action(Action::KillBgTask(task_id));
}
}
Some(TaskEntry::Agent { subagent_id, .. }) => {
let subagent_id = subagent_id.clone();
if self.subagent_sessions.values().any(|s| {
s.subagent_id.as_ref() == subagent_id && s.is_running() && !s.pending_kill
}) {
return InputOutcome::Action(Action::KillSubagent(subagent_id));
}
}
Some(TaskEntry::Scheduled { task_id, .. }) => {
return InputOutcome::Action(Action::CancelScheduledTask(task_id.clone()));
}
Some(TaskEntry::Header { .. }) => {}
None => {}
}
}
if key!('y').matches(key)
&& self.tasks.list_state.input_mode().is_none()
&& let Some(task_id) = self.tasks.selected_task_id().map(|s| s.to_string())
&& let Some(task) = self.session.bg_tasks.get(&task_id)
&& !task.stdout.is_empty()
{
let text = task.stdout.clone();
self.copy_to_clipboard(&text);
return InputOutcome::Changed;
}
if key!(Tab).matches(key) && self.tasks.list_state.input_mode().is_none() {
self.tasks.overlay.focused = false;
return InputOutcome::Action(Action::FocusPrompt);
}
let has_input = self.tasks.list_state.input_mode().is_some();
let action = handle_overlay_key(&mut self.tasks.overlay, key).or_else(|| {
if !has_input {
handle_overlay_nav_key(&mut self.tasks.overlay, key)
} else {
None
}
});
if let Some(action) = action {
self.tasks.on_state_change();
if !self.tasks.overlay.visible || !self.tasks.overlay.focused {
self.set_active_pane(AgentPane::Scrollback, false);
}
return overlay_action_to_outcome(action);
}
if self.tasks.handle_key(key) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
/// Subagent-pane-focused key handling.
pub(super) fn handle_catalog_key(
&mut self,
key: &KeyEvent,
_registry: &ActionRegistry,
) -> InputOutcome {
use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key};
let has_input = self.catalog.list_state.input_mode().is_some();
let action = handle_overlay_key(&mut self.catalog.overlay, key).or_else(|| {
if !has_input {
handle_overlay_nav_key(&mut self.catalog.overlay, key)
} else {
None
}
});
if let Some(action) = action {
self.catalog.on_state_change();
if !self.catalog.overlay.visible || !self.catalog.overlay.focused {
self.set_active_pane(AgentPane::Scrollback, false);
}
return overlay_action_to_outcome(action);
}
if key.code == crossterm::event::KeyCode::Enter
&& key.modifiers == crossterm::event::KeyModifiers::NONE
{
if let Some((kind, name)) = self.catalog.selected_entry() {
return InputOutcome::Action(Action::ViewCatalogEntry {
kind: kind.to_owned(),
name: name.to_owned(),
});
}
return InputOutcome::Unchanged;
}
if self.catalog.handle_key(key) {
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
/// Handle a normalized scroll event at a screen position.
///
/// Hit-tests against pane areas to decide what to scroll:
/// - Scrollback area → scroll the scrollback (uses accelerated line count)
/// - Prompt area → forward to textarea (which has its own scroll logic)
///
/// Positive `lines` = scroll down, negative = scroll up.
pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) {
if let Some(ref mut modal) = self.active_modal {
use crate::views::modal::ActiveModal;
match modal {
ActiveModal::CommandPalette { state, .. }
| ActiveModal::ArgPicker { state, .. }
| ActiveModal::SessionPicker { state, .. }
| ActiveModal::DocPicker { state, .. } => {
let delta = lines.unsigned_abs() as usize;
let current = state.scroll_offset.unwrap_or(0);
let new_offset = if lines > 0 {
current + delta
} else {
current.saturating_sub(delta)
};
state.scroll_offset = Some(new_offset);
state.hovered = None;
return;
}
ActiveModal::DocViewer { scroll, .. }
| ActiveModal::RememberNoteReview { scroll, .. } => {
crate::views::modal::apply_doc_scroll_delta(scroll, lines);
return;
}
_ => {}
}
}
if let Some(ref mut viewer) = self.block_viewer {
viewer.handle_scroll(lines);
return;
}
if self.rewind_state.is_some() {
if let Some(ref mut rw) = self.rewind_state {
crate::views::rewind::move_cursor(&mut rw.phase, lines.signum());
self.sync_rewind_anchor_to_picker();
}
return;
}
self.dismiss_jump_picker_if_suppressed();
if let Some(ref mut js) = self.jump_state {
crate::views::jump::move_cursor(js, lines.signum());
self.sync_jump_preview();
return;
}
if let Some(ref mut viewer) = self.line_viewer {
if let Some(area) = viewer.last_popup_area
&& area.contains((col, row).into())
{
viewer
.list_state
.handle_scroll_event(lines, col, row, &viewer.lines);
}
return;
}
if let Some(ref mut btw) = self.btw_state
&& matches!(btw, crate::views::btw_overlay::BtwOverlayState::Done { .. })
&& self.last_btw_area.area() > 0
&& self.last_btw_area.contains((col, row).into())
{
use crate::views::btw_overlay::DONE_MAX_BODY_LINES;
let max_body = DONE_MAX_BODY_LINES as usize;
let content_width = self.last_btw_area.width.saturating_sub(4) as usize;
let max_off = btw.max_scroll_offset(content_width, max_body);
if lines > 0 {
btw.scroll_down(lines as usize, max_off);
} else {
btw.scroll_up((-lines) as usize);
}
return;
}
if let Some(hd_area) = self.history_dropdown_area
&& hd_area.contains((col, row).into())
&& self.prompt.history_search.is_active()
{
let moved = if lines > 0 {
self.prompt.history_search.move_down()
} else if lines < 0 {
self.prompt.history_search.move_up()
} else {
false
};
if moved && self.prompt.history_search.is_browse() {
self.populate_prompt_from_history_selection();
}
return;
}
if let Some(dd_area) = self.dropdown_items_area
&& dd_area.contains((col, row).into())
{
self.prompt
.file_search
.move_selection(lines.signum() as isize);
return;
}
if let Some(dd_area) = self.slash_dropdown_items_area
&& dd_area.contains((col, row).into())
{
self.prompt.slash_scroll_selection(lines.signum() as isize);
self.prompt.slash_preview_current_selection();
return;
}
if let Some(dd_area) = self.completion_dropdown_items_area
&& dd_area.contains((col, row).into())
{
self.prompt
.completion_dropdown_scroll(lines.signum() as isize);
return;
}
if self.question_view.is_some() && self.pane_areas.prompt.contains((col, row).into()) {
if self
.inline_prompt_area
.is_some_and(|r| r.contains((col, row).into()))
{
let kind = if lines > 0 {
MouseEventKind::ScrollDown
} else {
MouseEventKind::ScrollUp
};
let event = MouseEvent {
kind,
column: col,
row,
modifiers: crossterm::event::KeyModifiers::NONE,
};
let _ = self.prompt.handle_mouse(&event);
} else if let Some((scroll_top, scroll_bottom)) = self.question_scroll_region
&& row >= scroll_top
&& row < scroll_bottom
{
self.apply_question_scroll(lines);
}
return;
}
let target = self
.pane_areas
.hit_test(col, row)
.unwrap_or(ActivePane::Scrollback);
match target {
ActivePane::Scrollback => {
if lines > 0 {
self.scrollback.scroll_down(lines as u16);
} else {
self.scrollback.scroll_up((-lines) as u16);
}
}
ActivePane::Todo => {
self.todo.handle_scroll(lines, col, row);
}
ActivePane::Queue => {
self.queue.handle_scroll(lines, col, row);
}
ActivePane::Tasks => {
self.tasks.handle_scroll(lines, col, row);
}
ActivePane::Catalog => {
self.catalog.handle_scroll(lines, col, row);
}
ActivePane::Prompt => {
if self.question_view.is_some() {
return;
}
let kind = if lines > 0 {
MouseEventKind::ScrollDown
} else {
MouseEventKind::ScrollUp
};
let event = MouseEvent {
kind,
column: col,
row,
modifiers: KeyModifiers::NONE,
};
self.prompt.handle_mouse(&event);
}
}
}
}
#[cfg(test)]
mod scroll_granularity_tests {
use super::super::test_fixtures::make_agent;
use crate::views::suggestion_controller::{
CompletionDropdownState, CompletionItemParsed, SuggestionSource,
};
use ratatui::layout::Rect;
/// Selection dropdowns step exactly one item per wheel dispatch: a
/// 3-line notch (or accelerated trackpad flush) must not skip items.
#[test]
fn wheel_notch_over_slash_dropdown_moves_selection_one_step() {
let mut agent = make_agent();
agent.prompt.set_text("/");
agent.prompt.refresh_slash(&agent.session.models);
assert!(agent.prompt.slash_open(), "precondition: dropdown open");
assert!(
agent.prompt.slash_snapshot().matches.len() >= 3,
"precondition: enough builtin commands to skip over"
);
assert_eq!(agent.prompt.slash_snapshot().selected, 0);
agent.slash_dropdown_items_area = Some(Rect::new(0, 0, 40, 8));
agent.handle_scroll(3, 5, 4);
assert_eq!(
agent.prompt.slash_snapshot().selected,
1,
"3-line wheel notch must move the slash selection by exactly 1"
);
agent.handle_scroll(-3, 5, 4);
assert_eq!(
agent.prompt.slash_snapshot().selected,
0,
"-3-line wheel notch must move the slash selection by exactly -1"
);
}
fn completion_item(label: &str) -> CompletionItemParsed {
CompletionItemParsed {
display: label.into(),
description: String::new(),
insert_text: label.into(),
source: SuggestionSource::History,
priority: 0,
replace_range: None,
token_text: None,
truncated: false,
}
}
#[test]
fn wheel_notch_over_completion_dropdown_moves_selection_one_step() {
let mut agent = make_agent();
agent.prompt.suggestions.dropdown = CompletionDropdownState {
open: true,
items: vec![
completion_item("a"),
completion_item("b"),
completion_item("c"),
],
selected: 0,
..Default::default()
};
agent.completion_dropdown_items_area = Some(Rect::new(0, 0, 40, 8));
agent.handle_scroll(3, 5, 4);
assert_eq!(
agent.prompt.suggestions.dropdown.selected, 1,
"3-line wheel notch must move the completion selection by exactly 1"
);
agent.handle_scroll(-3, 5, 4);
assert_eq!(
agent.prompt.suggestions.dropdown.selected, 0,
"-3-line wheel notch must move the completion selection by exactly -1"
);
}
}