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).
1507 lines
50 KiB
Rust
1507 lines
50 KiB
Rust
//! `/memory` browser modal -- view-state, rendering, and input handling.
|
|
//!
|
|
//! A centered popup with ModalWindow chrome, horizontally split into a
|
|
//! searchable file list (left, ~40%) and a read-only content preview
|
|
//! pane (right, ~60%). File list shows all memory files grouped by source
|
|
//! (Global, Workspace, Sessions) with session logs in reverse chronological
|
|
//! order. Selecting a file loads its content into the preview pane.
|
|
//!
|
|
//! Layout collapses to single-pane (list only) on narrow terminals (< 80 cols).
|
|
//!
|
|
//! `/` enters filter mode (type to search, Escape to exit).
|
|
//! `x` deletes with double-press confirmation (session logs only).
|
|
|
|
use std::borrow::Cow;
|
|
use std::path::PathBuf;
|
|
|
|
use crossterm::event::KeyModifiers;
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, MouseEventKind};
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::{Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::app::actions::Action;
|
|
use crate::app::app_view::InputOutcome;
|
|
|
|
use crate::render::SafeBuf;
|
|
use crate::render::scrollbar::{ScrollbarClickResult, render_scrollbar, scrollbar_click_to_offset};
|
|
use crate::scrollback::blocks::markdown_content::MarkdownContent;
|
|
use crate::theme::Theme;
|
|
use crate::views::modal_window::{
|
|
self, ModalContentArea, ModalSizing, ModalWindowConfig, ModalWindowState, Shortcut,
|
|
};
|
|
|
|
const SPLIT_MIN_WIDTH: u16 = 80;
|
|
const LIST_WIDTH_RATIO: f64 = 0.40;
|
|
const MAX_PREVIEW_BYTES: u64 = 1_048_576;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MemoryFileEntry {
|
|
pub path: PathBuf,
|
|
/// `"global"`, `"workspace"`, or `"session"`.
|
|
pub source: String,
|
|
pub label: String,
|
|
/// Pre-formatted `"size · modified"` string for the list row.
|
|
/// Empty for headers.
|
|
pub meta_display: String,
|
|
pub is_header: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum MemoryModalMode {
|
|
Browse,
|
|
/// Filter input is focused — all single-char keys go to the filter.
|
|
/// Only Escape (exit filter) and Enter (no-op / stay) remain active.
|
|
FilterFocused,
|
|
ConfirmingDelete {
|
|
idx: usize,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MemoryModalState {
|
|
pub window: ModalWindowState,
|
|
pub entries: Vec<MemoryFileEntry>,
|
|
pub selected: usize,
|
|
pub scroll_offset: usize,
|
|
pub preview_markdown: Option<MarkdownContent>,
|
|
pub preview_scroll: usize,
|
|
pub mode: MemoryModalMode,
|
|
pub query: String,
|
|
/// Whether memory is currently enabled for this session.
|
|
pub memory_enabled: bool,
|
|
/// Whether the modal is rendered in fullscreen mode (persisted to config).
|
|
pub fullscreen: bool,
|
|
/// Cached filtered indices. Recomputed when `query` or `entries` change.
|
|
filtered_cache: Vec<usize>,
|
|
/// Cached total lines in the preview (for scrollbar rendering).
|
|
preview_total_lines: usize,
|
|
/// Hit-test rects for mouse interaction (set during render).
|
|
list_area: Rect,
|
|
preview_area: Rect,
|
|
list_scrollbar_area: Option<Rect>,
|
|
preview_scrollbar_area: Option<Rect>,
|
|
}
|
|
|
|
impl MemoryModalState {
|
|
pub fn new(entries: Vec<MemoryFileEntry>) -> Self {
|
|
let filtered_cache = compute_filtered(&entries, "");
|
|
let mut state = Self {
|
|
window: ModalWindowState::new(),
|
|
entries,
|
|
selected: 0,
|
|
scroll_offset: 0,
|
|
preview_markdown: None,
|
|
preview_scroll: 0,
|
|
mode: MemoryModalMode::Browse,
|
|
query: String::new(),
|
|
memory_enabled: true,
|
|
fullscreen: load_fullscreen_pref(),
|
|
filtered_cache,
|
|
preview_total_lines: 0,
|
|
list_area: Rect::default(),
|
|
preview_area: Rect::default(),
|
|
list_scrollbar_area: None,
|
|
preview_scrollbar_area: None,
|
|
};
|
|
state.advance_past_headers();
|
|
state.load_preview();
|
|
state
|
|
}
|
|
|
|
pub fn filtered_indices(&self) -> &[usize] {
|
|
&self.filtered_cache
|
|
}
|
|
|
|
fn invalidate_filter(&mut self) {
|
|
self.filtered_cache = compute_filtered(&self.entries, &self.query);
|
|
}
|
|
|
|
pub fn selected_entry(&self) -> Option<&MemoryFileEntry> {
|
|
self.filtered_cache
|
|
.get(self.selected)
|
|
.and_then(|&i| self.entries.get(i))
|
|
}
|
|
|
|
/// Advance `selected` past any leading headers to the first selectable entry.
|
|
fn advance_past_headers(&mut self) {
|
|
let filtered = &self.filtered_cache;
|
|
for (i, &orig) in filtered.iter().enumerate() {
|
|
if !self.entries[orig].is_header {
|
|
self.selected = i;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn select_next(&mut self) {
|
|
if self.advance_next() {
|
|
self.load_preview();
|
|
}
|
|
}
|
|
|
|
pub fn select_prev(&mut self) {
|
|
if self.advance_prev() {
|
|
self.load_preview();
|
|
}
|
|
}
|
|
|
|
/// Move `selected` forward to the next non-header entry without
|
|
/// loading preview. Returns `true` if the selection changed.
|
|
fn advance_next(&mut self) -> bool {
|
|
let filtered = &self.filtered_cache;
|
|
let mut next = self.selected + 1;
|
|
while next < filtered.len() {
|
|
if !self.entries[filtered[next]].is_header {
|
|
self.selected = next;
|
|
return true;
|
|
}
|
|
next += 1;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Move `selected` backward to the previous non-header entry without
|
|
/// loading preview. Returns `true` if the selection changed.
|
|
fn advance_prev(&mut self) -> bool {
|
|
if self.selected == 0 {
|
|
return false;
|
|
}
|
|
let filtered = &self.filtered_cache;
|
|
let mut prev = self.selected - 1;
|
|
loop {
|
|
if !self.entries[filtered[prev]].is_header {
|
|
self.selected = prev;
|
|
return true;
|
|
}
|
|
if prev == 0 {
|
|
break;
|
|
}
|
|
prev -= 1;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Select the entry at a given filtered index (used by mouse click).
|
|
/// Skips headers. Returns `true` if the selection changed.
|
|
pub fn select_at(&mut self, filt_idx: usize) -> bool {
|
|
let filtered = &self.filtered_cache;
|
|
if filt_idx >= filtered.len() {
|
|
return false;
|
|
}
|
|
if self.entries[filtered[filt_idx]].is_header {
|
|
return false;
|
|
}
|
|
if self.selected == filt_idx {
|
|
return false;
|
|
}
|
|
self.selected = filt_idx;
|
|
self.load_preview();
|
|
true
|
|
}
|
|
|
|
pub fn clamp_selected(&mut self) {
|
|
let filtered = &self.filtered_cache;
|
|
if filtered.is_empty() {
|
|
self.selected = 0;
|
|
self.preview_markdown = None;
|
|
return;
|
|
}
|
|
if self.selected >= filtered.len() {
|
|
self.selected = filtered.len() - 1;
|
|
}
|
|
if self.entries[filtered[self.selected]].is_header {
|
|
// Try forward.
|
|
for (i, &orig) in filtered.iter().enumerate().skip(self.selected + 1) {
|
|
if !self.entries[orig].is_header {
|
|
self.selected = i;
|
|
self.load_preview();
|
|
return;
|
|
}
|
|
}
|
|
// Try backward.
|
|
for (i, &orig) in filtered.iter().enumerate().take(self.selected).rev() {
|
|
if !self.entries[orig].is_header {
|
|
self.selected = i;
|
|
self.load_preview();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
self.load_preview();
|
|
}
|
|
|
|
fn load_preview(&mut self) {
|
|
self.preview_scroll = 0;
|
|
self.preview_markdown = self
|
|
.selected_entry()
|
|
.filter(|e| !e.is_header)
|
|
.and_then(|e| {
|
|
let path = std::path::Path::new(&e.path);
|
|
match std::fs::metadata(path) {
|
|
Ok(meta) if meta.len() > MAX_PREVIEW_BYTES => {
|
|
Some(MarkdownContent::new("*(File too large to preview)*"))
|
|
}
|
|
Err(_) => None,
|
|
_ => std::fs::read_to_string(path).ok().map(MarkdownContent::new),
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Compute filtered indices from entries and query. Section headers are
|
|
/// preserved only when at least one entry in the section matches.
|
|
fn compute_filtered(entries: &[MemoryFileEntry], query: &str) -> Vec<usize> {
|
|
if query.is_empty() {
|
|
return (0..entries.len()).collect();
|
|
}
|
|
let needle = query.to_lowercase();
|
|
let mut result = Vec::new();
|
|
let mut pending_header: Option<usize> = None;
|
|
let mut section_has_match = false;
|
|
for (i, entry) in entries.iter().enumerate() {
|
|
if entry.is_header {
|
|
// Note: `pending_header` is `Some` here only when the previous
|
|
// section had zero matches (`.take()` clears it on first match),
|
|
// so `section_has_match` is always false — this guard is a
|
|
// defensive no-op. Kept for safety.
|
|
if let Some(h) = pending_header
|
|
&& section_has_match
|
|
{
|
|
result.push(h);
|
|
}
|
|
pending_header = Some(i);
|
|
section_has_match = false;
|
|
} else if entry.label.to_lowercase().contains(&needle)
|
|
|| entry.source.to_lowercase().contains(&needle)
|
|
{
|
|
if let Some(h) = pending_header.take() {
|
|
result.push(h);
|
|
section_has_match = true;
|
|
}
|
|
result.push(i);
|
|
}
|
|
}
|
|
// Same defensive no-op as the in-loop guard above.
|
|
if let Some(h) = pending_header
|
|
&& section_has_match
|
|
{
|
|
result.push(h);
|
|
}
|
|
result
|
|
}
|
|
|
|
pub fn build_entries(
|
|
files: Vec<kigi_shell::extensions::notification::MemoryFileInfo>,
|
|
) -> Vec<MemoryFileEntry> {
|
|
let now_secs = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
|
|
let mut global = Vec::new();
|
|
let mut workspace = Vec::new();
|
|
let mut session = Vec::new();
|
|
|
|
for f in files {
|
|
let label = file_label(&f.path);
|
|
let size = format_size(f.size_bytes);
|
|
let modified = format_modified(f.modified_epoch_secs, now_secs);
|
|
let meta_display = format!("{size} \u{00B7} {modified}");
|
|
let bucket = match f.source.as_str() {
|
|
"global" => &mut global,
|
|
"workspace" => &mut workspace,
|
|
_ => &mut session,
|
|
};
|
|
bucket.push(MemoryFileEntry {
|
|
label,
|
|
meta_display,
|
|
path: f.path.into(),
|
|
source: f.source,
|
|
is_header: false,
|
|
});
|
|
}
|
|
|
|
// Session logs: reverse chronological (newest first).
|
|
session.reverse();
|
|
|
|
let mut entries = Vec::new();
|
|
let mut push_section = |label: &str, items: Vec<MemoryFileEntry>| {
|
|
if !items.is_empty() {
|
|
entries.push(MemoryFileEntry {
|
|
path: PathBuf::new(),
|
|
source: String::new(),
|
|
meta_display: String::new(),
|
|
label: label.to_string(),
|
|
is_header: true,
|
|
});
|
|
entries.extend(items);
|
|
}
|
|
};
|
|
push_section("Global", global);
|
|
push_section("Workspace", workspace);
|
|
push_section("Sessions", session);
|
|
entries
|
|
}
|
|
|
|
pub fn render_memory_modal(
|
|
buf: &mut Buffer,
|
|
full_area: Rect,
|
|
state: &mut MemoryModalState,
|
|
compact: bool,
|
|
) {
|
|
let theme = Theme::current();
|
|
let shortcuts = build_shortcuts(&state.mode, state.memory_enabled, state.fullscreen);
|
|
|
|
let modal_config = ModalWindowConfig {
|
|
title: "Memory",
|
|
tabs: None,
|
|
shortcuts: &shortcuts,
|
|
sizing: if state.fullscreen {
|
|
ModalSizing {
|
|
width_pct: 1.0,
|
|
max_width: u16::MAX,
|
|
min_width: 44,
|
|
v_margin: 0,
|
|
h_pad: 2,
|
|
v_pad: 0,
|
|
footer_lines: 2,
|
|
}
|
|
} else {
|
|
ModalSizing {
|
|
width_pct: 0.75,
|
|
max_width: 140,
|
|
min_width: 44,
|
|
v_margin: 3,
|
|
h_pad: 2,
|
|
v_pad: 1,
|
|
footer_lines: 2,
|
|
}
|
|
}
|
|
.with_compact(compact),
|
|
fold_info: None,
|
|
};
|
|
|
|
let Some(ModalContentArea {
|
|
content: content_area,
|
|
..
|
|
}) =
|
|
modal_window::render_modal_window(buf, full_area, &mut state.window, &modal_config, &theme)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
if content_area.height < 2 || content_area.width < 10 {
|
|
return;
|
|
}
|
|
|
|
let show_preview = content_area.width >= SPLIT_MIN_WIDTH;
|
|
let list_width = if show_preview {
|
|
(content_area.width as f64 * LIST_WIDTH_RATIO) as u16
|
|
} else {
|
|
content_area.width
|
|
};
|
|
|
|
let list_area = Rect {
|
|
x: content_area.x,
|
|
y: content_area.y,
|
|
width: list_width,
|
|
height: content_area.height,
|
|
};
|
|
state.list_area = list_area;
|
|
|
|
render_file_list(buf, list_area, state, &theme);
|
|
|
|
if show_preview {
|
|
let preview_x = content_area.x + list_width + 1;
|
|
let preview_width = content_area.width.saturating_sub(list_width + 1);
|
|
if preview_width > 2 {
|
|
let sep_x = content_area.x + list_width;
|
|
let sep_style = Style::default().fg(theme.gray_dim);
|
|
for y in content_area.y..content_area.y + content_area.height {
|
|
if let Some(cell) = buf.cell_mut((sep_x, y)) {
|
|
cell.set_symbol("\u{2502}");
|
|
cell.set_style(sep_style);
|
|
}
|
|
}
|
|
|
|
let preview_area = Rect {
|
|
x: preview_x,
|
|
y: content_area.y,
|
|
width: preview_width,
|
|
height: content_area.height,
|
|
};
|
|
state.preview_area = preview_area;
|
|
render_preview(buf, preview_area, state, &theme);
|
|
} else {
|
|
state.preview_area = Rect::default();
|
|
state.preview_scrollbar_area = None;
|
|
}
|
|
} else {
|
|
state.preview_area = Rect::default();
|
|
state.preview_scrollbar_area = None;
|
|
}
|
|
}
|
|
|
|
fn render_file_list(buf: &mut Buffer, area: Rect, state: &mut MemoryModalState, theme: &Theme) {
|
|
let search_y = area.y;
|
|
let filter_focused = matches!(state.mode, MemoryModalMode::FilterFocused);
|
|
let (query_display, query_style) = if state.query.is_empty() {
|
|
let placeholder = if filter_focused {
|
|
"type to filter..."
|
|
} else {
|
|
"/ to filter..."
|
|
};
|
|
(
|
|
placeholder,
|
|
Style::default().fg(theme.gray_dim).bg(theme.bg_base),
|
|
)
|
|
} else {
|
|
(
|
|
state.query.as_str(),
|
|
Style::default().fg(theme.text_primary).bg(theme.bg_base),
|
|
)
|
|
};
|
|
buf.set_span(
|
|
area.x,
|
|
search_y,
|
|
&Span::styled(query_display, query_style),
|
|
area.width,
|
|
);
|
|
if filter_focused {
|
|
let cursor_x = area.x + state.query.width() as u16;
|
|
if cursor_x < area.x + area.width {
|
|
buf.set_span(
|
|
cursor_x,
|
|
search_y,
|
|
&Span::styled(
|
|
"\u{2588}",
|
|
Style::default().fg(theme.accent_user).bg(theme.bg_base),
|
|
),
|
|
1,
|
|
);
|
|
}
|
|
}
|
|
|
|
let entries_start_y = search_y + 1;
|
|
let available_height = area.height.saturating_sub(1) as usize;
|
|
if state.selected < state.scroll_offset {
|
|
state.scroll_offset = state.selected;
|
|
}
|
|
if state.selected >= state.scroll_offset + available_height {
|
|
state.scroll_offset = state.selected.saturating_sub(available_height - 1);
|
|
}
|
|
|
|
// Compute scrollbar area before borrowing filtered_cache.
|
|
let total_entries = sat_u16(state.filtered_indices().len());
|
|
let sb_area = if total_entries > available_height as u16 && area.width > 4 {
|
|
Some(Rect {
|
|
x: area.x + area.width - 1,
|
|
y: entries_start_y,
|
|
width: 1,
|
|
height: available_height as u16,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
state.list_scrollbar_area = sb_area;
|
|
let content_width = if sb_area.is_some() {
|
|
area.width.saturating_sub(2)
|
|
} else {
|
|
area.width
|
|
};
|
|
|
|
let filtered = state.filtered_indices();
|
|
let end = filtered.len().min(state.scroll_offset + available_height);
|
|
let visible = &filtered[state.scroll_offset..end];
|
|
|
|
for (row, &orig_idx) in visible.iter().enumerate() {
|
|
let y = entries_start_y + row as u16;
|
|
if y >= area.y + area.height {
|
|
break;
|
|
}
|
|
let entry = &state.entries[orig_idx];
|
|
let filt_idx = state.scroll_offset + row;
|
|
let is_selected = filt_idx == state.selected;
|
|
|
|
if entry.is_header {
|
|
let header_style = Style::default()
|
|
.fg(theme.accent_user)
|
|
.bg(theme.bg_base)
|
|
.add_modifier(Modifier::BOLD);
|
|
let line = Line::from(Span::styled(&entry.label, header_style));
|
|
buf.set_line(area.x, y, &line, content_width);
|
|
} else {
|
|
let bg = if is_selected {
|
|
theme.bg_visual
|
|
} else {
|
|
theme.bg_base
|
|
};
|
|
let label_style = Style::default().fg(theme.text_primary).bg(bg);
|
|
let meta_style = Style::default().fg(theme.gray).bg(bg);
|
|
|
|
let row_rect = Rect {
|
|
x: area.x,
|
|
y,
|
|
width: content_width,
|
|
height: 1,
|
|
};
|
|
buf.set_style(row_rect, Style::default().bg(bg));
|
|
|
|
let max_label_w = content_width.saturating_sub(2) as usize;
|
|
let truncated_label: Cow<str> = if entry.label.width() > max_label_w {
|
|
let trunc = truncate_to_width(&entry.label, max_label_w.saturating_sub(3));
|
|
format!("{trunc}...").into()
|
|
} else {
|
|
Cow::Borrowed(&entry.label)
|
|
};
|
|
buf.set_span(
|
|
area.x + 1,
|
|
y,
|
|
&Span::styled(truncated_label.as_ref(), label_style),
|
|
content_width.saturating_sub(1),
|
|
);
|
|
|
|
if is_selected
|
|
&& matches!(state.mode, MemoryModalMode::ConfirmingDelete { idx } if idx == filt_idx)
|
|
{
|
|
let hint = " [x to confirm]";
|
|
let hint_w = hint.len() as u16;
|
|
let hint_x = (area.x + content_width).saturating_sub(hint_w + 1);
|
|
buf.set_span(
|
|
hint_x,
|
|
y,
|
|
&Span::styled(hint, Style::default().fg(theme.accent_error).bg(bg)),
|
|
hint_w,
|
|
);
|
|
} else {
|
|
let meta_w = entry.meta_display.width() as u16;
|
|
let meta_x = (area.x + content_width).saturating_sub(meta_w + 1);
|
|
if meta_x > area.x + 1 {
|
|
buf.set_span(
|
|
meta_x,
|
|
y,
|
|
&Span::styled(&entry.meta_display, meta_style),
|
|
meta_w,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// List scrollbar.
|
|
render_scrollbar(
|
|
buf,
|
|
sb_area,
|
|
total_entries,
|
|
sat_u16(available_height),
|
|
sat_u16(state.scroll_offset),
|
|
false,
|
|
);
|
|
}
|
|
|
|
fn render_preview(buf: &mut Buffer, area: Rect, state: &mut MemoryModalState, theme: &Theme) {
|
|
if state.preview_markdown.is_none() {
|
|
state.preview_total_lines = 0;
|
|
state.preview_scrollbar_area = None;
|
|
let msg = "No file selected";
|
|
let style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
|
|
let cy = area.y + area.height / 2;
|
|
let cx = area.x + area.width.saturating_sub(msg.width() as u16) / 2;
|
|
buf.set_span(cx, cy, &Span::styled(msg, style), area.width);
|
|
return;
|
|
}
|
|
|
|
// Reserve scrollbar column if needed (computed after first pass).
|
|
// We do a two-pass approach: first compute total at full width to decide
|
|
// if scrollbar is needed, then re-compute at narrowed width if so.
|
|
let full_width = area.width as usize;
|
|
if full_width == 0 {
|
|
return;
|
|
}
|
|
|
|
buf.set_style(area, Style::default().bg(theme.bg_base));
|
|
|
|
let total_at_full = state
|
|
.preview_markdown
|
|
.as_ref()
|
|
.unwrap()
|
|
.with_wrapped_lines(full_width, |w| w.lines.len());
|
|
let visible = area.height as usize;
|
|
|
|
let (content_width, sb_area) = if total_at_full > visible && area.width > 4 {
|
|
let sb = Rect {
|
|
x: area.x + area.width - 1,
|
|
y: area.y,
|
|
width: 1,
|
|
height: area.height,
|
|
};
|
|
(full_width.saturating_sub(2), Some(sb))
|
|
} else {
|
|
(full_width, None)
|
|
};
|
|
|
|
let total = if sb_area.is_some() && content_width != full_width {
|
|
state
|
|
.preview_markdown
|
|
.as_ref()
|
|
.unwrap()
|
|
.with_wrapped_lines(content_width, |w| w.lines.len())
|
|
} else {
|
|
total_at_full
|
|
};
|
|
|
|
state.preview_total_lines = total;
|
|
state.preview_scrollbar_area = sb_area;
|
|
state.preview_scroll = state.preview_scroll.min(total.saturating_sub(visible));
|
|
let scroll = state.preview_scroll;
|
|
|
|
state
|
|
.preview_markdown
|
|
.as_ref()
|
|
.unwrap()
|
|
.with_wrapped_lines(content_width, |wrapped| {
|
|
for (row, line_idx) in (scroll..total.min(scroll + visible)).enumerate() {
|
|
let y = area.y + row as u16;
|
|
buf.set_line_safe(area.x, y, &wrapped.lines[line_idx], content_width as u16);
|
|
}
|
|
});
|
|
|
|
render_scrollbar(
|
|
buf,
|
|
sb_area,
|
|
sat_u16(total),
|
|
sat_u16(visible),
|
|
sat_u16(scroll),
|
|
false,
|
|
);
|
|
}
|
|
|
|
pub fn handle_memory_key(state: &mut MemoryModalState, key: &KeyEvent) -> InputOutcome {
|
|
if key.kind == KeyEventKind::Release {
|
|
return InputOutcome::Unchanged;
|
|
}
|
|
|
|
// ConfirmingDelete has an "any key cancel" contract — handle it first
|
|
// so no other handler can silently swallow the key.
|
|
if let MemoryModalMode::ConfirmingDelete { idx } = state.mode {
|
|
if key.code == KeyCode::Char('x') {
|
|
let filtered = state.filtered_indices();
|
|
if let Some(&orig_idx) = filtered.get(idx) {
|
|
let path = state.entries[orig_idx].path.clone();
|
|
state.entries.remove(orig_idx);
|
|
state.mode = MemoryModalMode::Browse;
|
|
state.invalidate_filter();
|
|
state.clamp_selected();
|
|
let _ = std::fs::remove_file(&path);
|
|
return InputOutcome::Changed;
|
|
}
|
|
}
|
|
state.mode = MemoryModalMode::Browse;
|
|
return InputOutcome::Changed;
|
|
}
|
|
|
|
match state.mode {
|
|
MemoryModalMode::ConfirmingDelete { .. } => unreachable!("handled above"),
|
|
MemoryModalMode::FilterFocused => handle_filter_focused(state, key),
|
|
MemoryModalMode::Browse => handle_browse(state, key),
|
|
}
|
|
}
|
|
|
|
/// Saturating cast from `usize` to `u16` (caps at `u16::MAX`).
|
|
fn sat_u16(v: usize) -> u16 {
|
|
v.min(u16::MAX as usize) as u16
|
|
}
|
|
|
|
/// After a list scrollbar jump, select the first visible non-header entry
|
|
/// so that `render_file_list`'s scroll-clamping doesn't snap `scroll_offset`
|
|
/// back to the old selection.
|
|
fn select_first_visible(state: &mut MemoryModalState) {
|
|
let filtered = state.filtered_indices();
|
|
let visible_start = state.scroll_offset;
|
|
let visible_end = filtered
|
|
.len()
|
|
.min(visible_start + state.list_area.height.saturating_sub(1) as usize);
|
|
for (i, &orig) in filtered
|
|
.iter()
|
|
.enumerate()
|
|
.take(visible_end)
|
|
.skip(visible_start)
|
|
{
|
|
if !state.entries[orig].is_header {
|
|
state.selected = i;
|
|
state.load_preview();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Apply a scrollbar click/drag at `screen_row` within `sb_area`, updating `offset`.
|
|
fn apply_scrollbar_jump(
|
|
screen_row: u16,
|
|
sb_area: Rect,
|
|
total_lines: u16,
|
|
viewport_lines: u16,
|
|
offset: &mut usize,
|
|
) {
|
|
let cell_index = screen_row.saturating_sub(sb_area.y);
|
|
let result = scrollbar_click_to_offset(cell_index, sb_area.height, total_lines, viewport_lines);
|
|
let max_scroll = (total_lines as usize).saturating_sub(viewport_lines as usize);
|
|
match result {
|
|
ScrollbarClickResult::Top => *offset = 0,
|
|
ScrollbarClickResult::Bottom => *offset = max_scroll,
|
|
ScrollbarClickResult::Offset(o) => *offset = o.min(max_scroll),
|
|
}
|
|
}
|
|
|
|
/// Handle mouse events for the memory modal content area.
|
|
///
|
|
/// Supports:
|
|
/// - Left-click on a file list row to select it
|
|
/// - Scroll wheel over the file list to scroll the list
|
|
/// - Scroll wheel over the preview pane to scroll the preview
|
|
/// - Click/drag on list scrollbar to jump-scroll the list
|
|
/// - Click/drag on preview scrollbar to jump-scroll the preview
|
|
pub fn handle_memory_mouse(
|
|
state: &mut MemoryModalState,
|
|
kind: MouseEventKind,
|
|
column: u16,
|
|
row: u16,
|
|
) -> InputOutcome {
|
|
let in_rect = |r: Rect| -> bool {
|
|
r.width > 0
|
|
&& r.height > 0
|
|
&& column >= r.x
|
|
&& column < r.x + r.width
|
|
&& row >= r.y
|
|
&& row < r.y + r.height
|
|
};
|
|
|
|
let on_list = in_rect(state.list_area);
|
|
let on_preview = in_rect(state.preview_area);
|
|
let on_list_sb = state.list_scrollbar_area.is_some_and(&in_rect);
|
|
let on_preview_sb = state.preview_scrollbar_area.is_some_and(&in_rect);
|
|
|
|
match kind {
|
|
MouseEventKind::Down(crossterm::event::MouseButton::Left)
|
|
| MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
|
|
// Click/drag on list scrollbar → jump-scroll + select nearest entry.
|
|
if on_list_sb {
|
|
let sb = state.list_scrollbar_area.unwrap();
|
|
let total = sat_u16(state.filtered_indices().len());
|
|
let visible = state.list_area.height.saturating_sub(1);
|
|
apply_scrollbar_jump(row, sb, total, visible, &mut state.scroll_offset);
|
|
select_first_visible(state);
|
|
return InputOutcome::Changed;
|
|
}
|
|
|
|
// Click/drag on preview scrollbar → jump-scroll.
|
|
if on_preview_sb {
|
|
let sb = state.preview_scrollbar_area.unwrap();
|
|
let total = sat_u16(state.preview_total_lines);
|
|
let visible = state.preview_area.height;
|
|
apply_scrollbar_jump(row, sb, total, visible, &mut state.preview_scroll);
|
|
return InputOutcome::Changed;
|
|
}
|
|
|
|
// Click on a file list row to select it (not on drag).
|
|
if matches!(kind, MouseEventKind::Down(_)) && on_list {
|
|
let entries_start_y = state.list_area.y + 1;
|
|
if row >= entries_start_y {
|
|
let clicked_row = (row - entries_start_y) as usize;
|
|
let filt_idx = state.scroll_offset + clicked_row;
|
|
if state.select_at(filt_idx) {
|
|
return InputOutcome::Changed;
|
|
}
|
|
}
|
|
}
|
|
|
|
InputOutcome::Unchanged
|
|
}
|
|
|
|
MouseEventKind::ScrollDown => {
|
|
if on_list || on_list_sb {
|
|
let mut moved = false;
|
|
for _ in 0..3 {
|
|
moved |= state.advance_next();
|
|
}
|
|
if moved {
|
|
state.load_preview();
|
|
}
|
|
return InputOutcome::Changed;
|
|
}
|
|
if on_preview || on_preview_sb {
|
|
let max = state
|
|
.preview_total_lines
|
|
.saturating_sub(state.preview_area.height as usize);
|
|
state.preview_scroll = state.preview_scroll.saturating_add(3).min(max);
|
|
return InputOutcome::Changed;
|
|
}
|
|
InputOutcome::Unchanged
|
|
}
|
|
|
|
MouseEventKind::ScrollUp => {
|
|
if on_list || on_list_sb {
|
|
let mut moved = false;
|
|
for _ in 0..3 {
|
|
moved |= state.advance_prev();
|
|
}
|
|
if moved {
|
|
state.load_preview();
|
|
}
|
|
return InputOutcome::Changed;
|
|
}
|
|
if on_preview || on_preview_sb {
|
|
state.preview_scroll = state.preview_scroll.saturating_sub(3);
|
|
return InputOutcome::Changed;
|
|
}
|
|
InputOutcome::Unchanged
|
|
}
|
|
|
|
_ => InputOutcome::Unchanged,
|
|
}
|
|
}
|
|
|
|
/// Keys while the filter input is focused: all chars go to the filter,
|
|
/// only Escape exits filter mode. Arrow keys still navigate the list.
|
|
fn handle_filter_focused(state: &mut MemoryModalState, key: &KeyEvent) -> InputOutcome {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
state.mode = MemoryModalMode::Browse;
|
|
InputOutcome::Changed
|
|
}
|
|
KeyCode::Down => {
|
|
state.select_next();
|
|
InputOutcome::Changed
|
|
}
|
|
KeyCode::Up => {
|
|
state.select_prev();
|
|
InputOutcome::Changed
|
|
}
|
|
KeyCode::Char(c) if crate::input::key::is_text_input_key(key) => {
|
|
state.query.push(c);
|
|
state.invalidate_filter();
|
|
state.clamp_selected();
|
|
InputOutcome::Changed
|
|
}
|
|
KeyCode::Backspace => {
|
|
if state.query.pop().is_some() {
|
|
state.invalidate_filter();
|
|
state.clamp_selected();
|
|
InputOutcome::Changed
|
|
} else {
|
|
InputOutcome::Unchanged
|
|
}
|
|
}
|
|
_ => InputOutcome::Unchanged,
|
|
}
|
|
}
|
|
|
|
/// Keys in normal browse mode: single-char hotkeys active, `/` enters
|
|
/// filter mode.
|
|
fn handle_browse(state: &mut MemoryModalState, key: &KeyEvent) -> InputOutcome {
|
|
if key.code == KeyCode::Char('f') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
state.fullscreen = !state.fullscreen;
|
|
return InputOutcome::Action(Action::PersistMemoryFullscreen(state.fullscreen));
|
|
}
|
|
match key.code {
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
state.select_next();
|
|
InputOutcome::Changed
|
|
}
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
state.select_prev();
|
|
InputOutcome::Changed
|
|
}
|
|
KeyCode::PageDown => {
|
|
let mut moved = false;
|
|
for _ in 0..10 {
|
|
moved |= state.advance_next();
|
|
}
|
|
if moved {
|
|
state.load_preview();
|
|
}
|
|
InputOutcome::Changed
|
|
}
|
|
KeyCode::PageUp => {
|
|
let mut moved = false;
|
|
for _ in 0..10 {
|
|
moved |= state.advance_prev();
|
|
}
|
|
if moved {
|
|
state.load_preview();
|
|
}
|
|
InputOutcome::Changed
|
|
}
|
|
KeyCode::Char('x') if key.modifiers.is_empty() => {
|
|
if let Some(entry) = state.selected_entry()
|
|
&& !entry.is_header
|
|
&& entry.source == "session"
|
|
{
|
|
state.mode = MemoryModalMode::ConfirmingDelete {
|
|
idx: state.selected,
|
|
};
|
|
return InputOutcome::Changed;
|
|
}
|
|
InputOutcome::Unchanged
|
|
}
|
|
KeyCode::Char('y') if key.modifiers.is_empty() => {
|
|
if let Some(entry) = state.selected_entry()
|
|
&& !entry.is_header
|
|
{
|
|
let _ = crate::clipboard::copy_text(&entry.path.to_string_lossy());
|
|
return InputOutcome::Changed;
|
|
}
|
|
InputOutcome::Unchanged
|
|
}
|
|
KeyCode::Char('t') if key.modifiers.is_empty() => {
|
|
let cmd = if state.memory_enabled {
|
|
"/memory off"
|
|
} else {
|
|
"/memory on"
|
|
};
|
|
// Optimistic update — the shortcut bar reflects the new state
|
|
// immediately. The shell re-syncs via the next MemoryFiles
|
|
// notification if the command fails.
|
|
state.memory_enabled = !state.memory_enabled;
|
|
InputOutcome::Action(Action::SendSlashCommandPreservingDraft(cmd.to_owned()))
|
|
}
|
|
// `i` aliases `/` (vim-nav "press i to search").
|
|
KeyCode::Char('/') | KeyCode::Char('i') if key.modifiers.is_empty() => {
|
|
state.mode = MemoryModalMode::FilterFocused;
|
|
InputOutcome::Changed
|
|
}
|
|
KeyCode::Backspace => {
|
|
if state.query.pop().is_some() {
|
|
state.invalidate_filter();
|
|
state.clamp_selected();
|
|
InputOutcome::Changed
|
|
} else {
|
|
InputOutcome::Unchanged
|
|
}
|
|
}
|
|
_ => InputOutcome::Unchanged,
|
|
}
|
|
}
|
|
|
|
fn build_shortcuts(
|
|
mode: &MemoryModalMode,
|
|
memory_enabled: bool,
|
|
fullscreen: bool,
|
|
) -> Vec<Shortcut<'static>> {
|
|
match mode {
|
|
MemoryModalMode::Browse => {
|
|
let toggle_label = if memory_enabled {
|
|
"t toggle (on)"
|
|
} else {
|
|
"t toggle (off)"
|
|
};
|
|
let mut shortcuts = vec![
|
|
Shortcut {
|
|
label: "\u{2191}/\u{2193} nav",
|
|
clickable: false,
|
|
id: 0,
|
|
},
|
|
Shortcut {
|
|
label: "/ search",
|
|
clickable: false,
|
|
id: 0,
|
|
},
|
|
Shortcut {
|
|
label: "y copy path",
|
|
clickable: false,
|
|
id: 0,
|
|
},
|
|
Shortcut {
|
|
label: "x delete",
|
|
clickable: false,
|
|
id: 0,
|
|
},
|
|
Shortcut {
|
|
label: toggle_label,
|
|
clickable: false,
|
|
id: 0,
|
|
},
|
|
Shortcut {
|
|
label: if fullscreen {
|
|
"^F normal"
|
|
} else {
|
|
"^F fullscreen"
|
|
},
|
|
clickable: false,
|
|
id: 0,
|
|
},
|
|
Shortcut {
|
|
label: "Esc close",
|
|
clickable: false,
|
|
id: 0,
|
|
},
|
|
];
|
|
// Browse is nav mode (filter inactive), so append `i search` last
|
|
// (matching the shared pickers).
|
|
modal_window::push_vim_nav_search_hint(&mut shortcuts, false);
|
|
shortcuts
|
|
}
|
|
MemoryModalMode::FilterFocused => vec![
|
|
Shortcut {
|
|
label: "type to filter",
|
|
clickable: false,
|
|
id: 0,
|
|
},
|
|
Shortcut {
|
|
label: "Esc exit filter",
|
|
clickable: false,
|
|
id: 0,
|
|
},
|
|
],
|
|
MemoryModalMode::ConfirmingDelete { .. } => vec![
|
|
Shortcut {
|
|
label: "x confirm delete",
|
|
clickable: false,
|
|
id: 0,
|
|
},
|
|
Shortcut {
|
|
label: "any key cancel",
|
|
clickable: false,
|
|
id: 0,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
/// Truncate a string to fit within `max_width` display columns.
|
|
/// Delegates to `render::line_utils::byte_offset_at_width` to avoid
|
|
/// duplicating the Unicode-width scanning logic.
|
|
fn truncate_to_width(s: &str, max_width: usize) -> &str {
|
|
let offset = crate::render::line_utils::byte_offset_at_width(s, max_width);
|
|
&s[..offset]
|
|
}
|
|
|
|
fn file_label(path: &str) -> String {
|
|
let p = std::path::Path::new(path);
|
|
p.file_name()
|
|
.map(|f| f.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| path.to_string())
|
|
}
|
|
|
|
fn format_size(bytes: u64) -> String {
|
|
if bytes < 1024 {
|
|
return format!("{bytes}B");
|
|
}
|
|
let kb = bytes as f64 / 1024.0;
|
|
if kb < 1024.0 {
|
|
return format!("{kb:.1}KB");
|
|
}
|
|
let mb = kb / 1024.0;
|
|
format!("{mb:.1}MB")
|
|
}
|
|
|
|
fn format_modified(epoch_secs: Option<u64>, now_secs: u64) -> String {
|
|
let Some(modified) = epoch_secs else {
|
|
return "unknown".to_string();
|
|
};
|
|
if now_secs <= modified {
|
|
return "just now".to_string();
|
|
}
|
|
let delta = now_secs - modified;
|
|
if delta < 60 {
|
|
return "just now".to_string();
|
|
}
|
|
if delta < 3600 {
|
|
let mins = delta / 60;
|
|
return format!("{mins}m ago");
|
|
}
|
|
if delta < 86400 {
|
|
let hours = delta / 3600;
|
|
return format!("{hours}h ago");
|
|
}
|
|
let days = delta / 86400;
|
|
format!("{days}d ago")
|
|
}
|
|
|
|
fn load_fullscreen_pref() -> bool {
|
|
let path = kigi_tools::util::kigi_home::kigi_home().join("config.toml");
|
|
let Some(doc) = crate::config_toml_edit::read_config_document_for_edit(&path) else {
|
|
return false;
|
|
};
|
|
doc.get("hints")
|
|
.and_then(|h| h.get("memory_modal_fullscreen"))
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn format_size_ranges() {
|
|
assert_eq!(format_size(0), "0B");
|
|
assert_eq!(format_size(512), "512B");
|
|
assert_eq!(format_size(1024), "1.0KB");
|
|
assert_eq!(format_size(1536), "1.5KB");
|
|
assert_eq!(format_size(1048576), "1.0MB");
|
|
}
|
|
|
|
#[test]
|
|
fn format_modified_relative() {
|
|
let now = 1_700_000_000u64;
|
|
assert_eq!(format_modified(None, now), "unknown");
|
|
assert_eq!(format_modified(Some(now), now), "just now");
|
|
assert_eq!(format_modified(Some(now - 30), now), "just now");
|
|
assert_eq!(format_modified(Some(now - 120), now), "2m ago");
|
|
assert_eq!(format_modified(Some(now - 7200), now), "2h ago");
|
|
assert_eq!(format_modified(Some(now - 172800), now), "2d ago");
|
|
}
|
|
|
|
#[test]
|
|
fn file_label_extracts_filename() {
|
|
assert_eq!(file_label("/home/user/.kigi/memory/MEMORY.md"), "MEMORY.md");
|
|
assert_eq!(
|
|
file_label("/workspace/.kigi/memory/sessions/2026-01-15-fix-bug.md"),
|
|
"2026-01-15-fix-bug.md"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_entries_groups_by_source() {
|
|
use kigi_shell::extensions::notification::MemoryFileInfo;
|
|
|
|
let files = vec![
|
|
MemoryFileInfo {
|
|
path: "/global/MEMORY.md".into(),
|
|
source: "global".into(),
|
|
size_bytes: 100,
|
|
modified_epoch_secs: Some(1_700_000_000),
|
|
},
|
|
MemoryFileInfo {
|
|
path: "/workspace/MEMORY.md".into(),
|
|
source: "workspace".into(),
|
|
size_bytes: 200,
|
|
modified_epoch_secs: Some(1_700_000_000),
|
|
},
|
|
MemoryFileInfo {
|
|
path: "/sessions/log1.md".into(),
|
|
source: "session".into(),
|
|
size_bytes: 50,
|
|
modified_epoch_secs: None,
|
|
},
|
|
];
|
|
|
|
let entries = build_entries(files);
|
|
assert_eq!(entries.len(), 6);
|
|
assert!(entries[0].is_header);
|
|
assert_eq!(entries[0].label, "Global");
|
|
assert!(!entries[1].is_header);
|
|
assert!(entries[2].is_header);
|
|
assert_eq!(entries[2].label, "Workspace");
|
|
assert!(entries[4].is_header);
|
|
assert_eq!(entries[4].label, "Sessions");
|
|
}
|
|
|
|
#[test]
|
|
fn new_selects_first_non_header() {
|
|
let entries = build_test_entries();
|
|
let state = MemoryModalState::new(entries);
|
|
// Index 0 is a header; selected should skip to index 1.
|
|
assert_eq!(state.selected, 1);
|
|
let sel = state.selected_entry().unwrap();
|
|
assert!(!sel.is_header);
|
|
}
|
|
|
|
#[test]
|
|
fn filtered_indices_preserves_headers_for_matching_entries() {
|
|
let entries = build_test_entries();
|
|
let mut state = MemoryModalState::new(entries);
|
|
state.query = "memory".to_string();
|
|
state.invalidate_filter();
|
|
|
|
let indices = state.filtered_indices();
|
|
assert_eq!(indices.len(), 2);
|
|
assert_eq!(indices[0], 0); // Global header
|
|
assert_eq!(indices[1], 1); // MEMORY.md
|
|
}
|
|
|
|
#[test]
|
|
fn delete_only_allowed_for_session_entries() {
|
|
let entries = build_test_entries();
|
|
let mut state = MemoryModalState::new(entries);
|
|
// Select the global entry (index 1 in filtered, which is "MEMORY.md" source="global")
|
|
state.selected = 1;
|
|
// Try to delete — should NOT enter confirming mode because source is "global".
|
|
let key = crossterm::event::KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
|
|
let outcome = handle_memory_key(&mut state, &key);
|
|
assert!(matches!(outcome, InputOutcome::Unchanged));
|
|
assert_eq!(state.mode, MemoryModalMode::Browse);
|
|
|
|
// Select the session entry (index 3 in filtered, which is "session-log.md" source="session")
|
|
state.selected = 3;
|
|
let outcome = handle_memory_key(&mut state, &key);
|
|
assert!(matches!(outcome, InputOutcome::Changed));
|
|
assert!(matches!(
|
|
state.mode,
|
|
MemoryModalMode::ConfirmingDelete { .. }
|
|
));
|
|
}
|
|
|
|
/// `i` aliases `/` without modifiers: from Browse it enters FilterFocused
|
|
/// exactly like `/` (vim-nav "press i to search").
|
|
#[test]
|
|
fn i_key_enters_filter_like_slash() {
|
|
let mut state = MemoryModalState::new(build_test_entries());
|
|
assert_eq!(state.mode, MemoryModalMode::Browse);
|
|
let i = crossterm::event::KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
|
|
assert!(matches!(
|
|
handle_memory_key(&mut state, &i),
|
|
InputOutcome::Changed
|
|
));
|
|
assert_eq!(state.mode, MemoryModalMode::FilterFocused);
|
|
}
|
|
|
|
/// The `modifiers.is_empty()` guard: Ctrl+i / Alt+i must NOT enter filter.
|
|
#[test]
|
|
fn modified_i_does_not_enter_filter() {
|
|
for mods in [KeyModifiers::CONTROL, KeyModifiers::ALT] {
|
|
let mut state = MemoryModalState::new(build_test_entries());
|
|
let k = crossterm::event::KeyEvent::new(KeyCode::Char('i'), mods);
|
|
assert!(matches!(
|
|
handle_memory_key(&mut state, &k),
|
|
InputOutcome::Unchanged
|
|
));
|
|
assert_eq!(state.mode, MemoryModalMode::Browse);
|
|
}
|
|
}
|
|
|
|
/// Wiring check: the Browse footer carries the shared `i search` hint under
|
|
/// vim nav mode. The gate is covered centrally by `modal_window`'s
|
|
/// `vim_nav_search_hint_only_in_vim_nav_mode`. The explicit `set_vim_mode`
|
|
/// pin (a thread-local that, once set, blocks disk-seeding) keeps this
|
|
/// independent of the dev's on-disk `[ui].vim_mode`; reset afterward since
|
|
/// libtest reuses worker threads.
|
|
#[test]
|
|
fn browse_footer_advertises_i_search_under_vim() {
|
|
crate::appearance::cache::set_vim_mode(true);
|
|
let vim = build_shortcuts(&MemoryModalMode::Browse, true, false);
|
|
assert!(
|
|
vim.iter().any(|s| s.label == "i search"),
|
|
"vim-mode Browse footer must advertise `i search`"
|
|
);
|
|
crate::appearance::cache::set_vim_mode(false);
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_to_width_handles_ascii() {
|
|
assert_eq!(truncate_to_width("hello world", 5), "hello");
|
|
assert_eq!(truncate_to_width("hello", 10), "hello");
|
|
assert_eq!(truncate_to_width("", 5), "");
|
|
}
|
|
|
|
#[test]
|
|
fn truncate_to_width_handles_multibyte() {
|
|
// CJK characters are 2 columns wide.
|
|
let s = "\u{4F60}\u{597D}world"; // 你好world — 2+2+5 = 9 cols
|
|
assert_eq!(truncate_to_width(s, 4), "\u{4F60}\u{597D}"); // 2+2 = 4
|
|
assert_eq!(truncate_to_width(s, 3), "\u{4F60}"); // 2, next char is 2 → exceeds 3
|
|
assert_eq!(truncate_to_width(s, 9), s); // fits
|
|
}
|
|
|
|
#[test]
|
|
fn cached_filter_updates_on_invalidate() {
|
|
let entries = build_test_entries();
|
|
let mut state = MemoryModalState::new(entries);
|
|
assert_eq!(state.filtered_indices().len(), 4); // all entries
|
|
|
|
state.query = "session".to_string();
|
|
state.invalidate_filter();
|
|
// Only the Sessions header + session-log.md should match.
|
|
assert_eq!(state.filtered_indices().len(), 2);
|
|
|
|
state.query.clear();
|
|
state.invalidate_filter();
|
|
assert_eq!(state.filtered_indices().len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn select_at_skips_headers_and_updates() {
|
|
let entries = build_test_entries();
|
|
let mut state = MemoryModalState::new(entries);
|
|
// Initial selection is index 1 (first non-header).
|
|
assert_eq!(state.selected, 1);
|
|
|
|
// Select the session entry at filtered index 3.
|
|
assert!(state.select_at(3));
|
|
assert_eq!(state.selected, 3);
|
|
let sel = state.selected_entry().unwrap();
|
|
assert_eq!(sel.label, "session-log.md");
|
|
|
|
// Selecting a header should fail.
|
|
assert!(!state.select_at(0));
|
|
assert_eq!(state.selected, 3);
|
|
|
|
// Selecting the same index again should return false.
|
|
assert!(!state.select_at(3));
|
|
}
|
|
|
|
#[test]
|
|
fn select_at_out_of_bounds() {
|
|
let entries = build_test_entries();
|
|
let mut state = MemoryModalState::new(entries);
|
|
assert!(!state.select_at(100));
|
|
assert_eq!(state.selected, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_click_selects_file() {
|
|
let entries = build_test_entries();
|
|
let mut state = MemoryModalState::new(entries);
|
|
// Simulate a rendered list area.
|
|
state.list_area = Rect::new(5, 10, 30, 20);
|
|
state.scroll_offset = 0;
|
|
|
|
// Click on row corresponding to filtered index 3 (entries_start_y = 11, row 3 → y=14).
|
|
let result = handle_memory_mouse(
|
|
&mut state,
|
|
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
|
10,
|
|
14,
|
|
);
|
|
assert!(matches!(result, InputOutcome::Changed));
|
|
assert_eq!(state.selected, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_click_on_header_unchanged() {
|
|
let entries = build_test_entries();
|
|
let mut state = MemoryModalState::new(entries);
|
|
state.list_area = Rect::new(5, 10, 30, 20);
|
|
state.scroll_offset = 0;
|
|
|
|
// Click on row 0 (header "Global" at y=11).
|
|
let result = handle_memory_mouse(
|
|
&mut state,
|
|
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
|
10,
|
|
11,
|
|
);
|
|
assert!(matches!(result, InputOutcome::Unchanged));
|
|
assert_eq!(state.selected, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_scroll_up_down_on_list() {
|
|
let entries = build_test_entries();
|
|
let mut state = MemoryModalState::new(entries);
|
|
state.list_area = Rect::new(0, 0, 30, 10);
|
|
|
|
// Scroll down on the list area.
|
|
let result = handle_memory_mouse(&mut state, MouseEventKind::ScrollDown, 5, 3);
|
|
assert!(matches!(result, InputOutcome::Changed));
|
|
|
|
// Scroll up on the list area.
|
|
let result = handle_memory_mouse(&mut state, MouseEventKind::ScrollUp, 5, 3);
|
|
assert!(matches!(result, InputOutcome::Changed));
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_scroll_on_preview() {
|
|
let entries = build_test_entries();
|
|
let mut state = MemoryModalState::new(entries);
|
|
state.preview_area = Rect::new(40, 0, 30, 10);
|
|
state.preview_total_lines = 100;
|
|
state.preview_scroll = 5;
|
|
|
|
// Scroll down on the preview area.
|
|
let result = handle_memory_mouse(&mut state, MouseEventKind::ScrollDown, 50, 3);
|
|
assert!(matches!(result, InputOutcome::Changed));
|
|
assert_eq!(state.preview_scroll, 8);
|
|
|
|
// Scroll up on the preview area.
|
|
let result = handle_memory_mouse(&mut state, MouseEventKind::ScrollUp, 50, 3);
|
|
assert!(matches!(result, InputOutcome::Changed));
|
|
assert_eq!(state.preview_scroll, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn mouse_outside_both_panes_unchanged() {
|
|
let entries = build_test_entries();
|
|
let mut state = MemoryModalState::new(entries);
|
|
state.list_area = Rect::new(0, 0, 30, 10);
|
|
state.preview_area = Rect::new(40, 0, 30, 10);
|
|
|
|
// Click outside both areas.
|
|
let result = handle_memory_mouse(
|
|
&mut state,
|
|
MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
|
35,
|
|
5,
|
|
);
|
|
assert!(matches!(result, InputOutcome::Unchanged));
|
|
}
|
|
|
|
#[test]
|
|
fn ctrl_d_u_no_longer_scrolls_preview() {
|
|
let entries = build_test_entries();
|
|
let mut state = MemoryModalState::new(entries);
|
|
state.preview_scroll = 5;
|
|
|
|
// Ctrl+D should NOT scroll preview (removed hotkey).
|
|
let key = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
|
|
let result = handle_memory_key(&mut state, &key);
|
|
assert!(matches!(result, InputOutcome::Unchanged));
|
|
assert_eq!(state.preview_scroll, 5);
|
|
|
|
// Ctrl+U should NOT scroll preview (removed hotkey).
|
|
let key = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
|
|
let result = handle_memory_key(&mut state, &key);
|
|
assert!(matches!(result, InputOutcome::Unchanged));
|
|
assert_eq!(state.preview_scroll, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_scrollbar_jump_edges() {
|
|
let mut offset = 50;
|
|
// Top click → offset 0.
|
|
apply_scrollbar_jump(10, Rect::new(0, 10, 1, 20), 100, 10, &mut offset);
|
|
assert_eq!(offset, 0);
|
|
|
|
// Bottom click → max offset.
|
|
apply_scrollbar_jump(29, Rect::new(0, 10, 1, 20), 100, 10, &mut offset);
|
|
assert_eq!(offset, 90);
|
|
}
|
|
|
|
fn build_test_entries() -> Vec<MemoryFileEntry> {
|
|
vec![
|
|
MemoryFileEntry {
|
|
path: PathBuf::new(),
|
|
source: String::new(),
|
|
meta_display: String::new(),
|
|
label: "Global".to_string(),
|
|
is_header: true,
|
|
},
|
|
MemoryFileEntry {
|
|
path: PathBuf::from("/test/MEMORY.md"),
|
|
source: "global".into(),
|
|
meta_display: "1KB \u{00B7} 1d ago".into(),
|
|
label: "MEMORY.md".to_string(),
|
|
is_header: false,
|
|
},
|
|
MemoryFileEntry {
|
|
path: PathBuf::new(),
|
|
source: String::new(),
|
|
meta_display: String::new(),
|
|
label: "Sessions".to_string(),
|
|
is_header: true,
|
|
},
|
|
MemoryFileEntry {
|
|
path: PathBuf::from("/test/session.md"),
|
|
source: "session".into(),
|
|
meta_display: "500B \u{00B7} 2d ago".into(),
|
|
label: "session-log.md".to_string(),
|
|
is_header: false,
|
|
},
|
|
]
|
|
}
|
|
}
|