From 9a9125a89dc354895737e472fb35d73a862c2ab1 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 14:35:49 -0400 Subject: [PATCH 01/69] docs: design spec for change chronology view --- ...026-06-05-change-chronology-view-design.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-05-change-chronology-view-design.md diff --git a/docs/superpowers/specs/2026-06-05-change-chronology-view-design.md b/docs/superpowers/specs/2026-06-05-change-chronology-view-design.md new file mode 100644 index 0000000..48ca741 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-change-chronology-view-design.md @@ -0,0 +1,259 @@ +# Change Chronology View — Design + +**Date:** 2026-06-05 +**Status:** Approved for planning + +## Problem + +Agentic coding erodes the developer's "muscle memory" of the codebase. When you +aren't typing the edits yourself, you lose the felt sense of where code lives, +what each file does, and *why* an implementation took the shape it did. Reading +diffs after the fact doesn't rebuild that memory — a diff shows *what* changed +but not the lived, ordered narrative of the agent working through the change. + +wsx is uniquely positioned to help: it already observes agent sessions +read-only by tailing their JSONL logs, so it can surface a faithful, moment-by- +moment record of what the agent touched and when. + +## Goal + +A toggleable vertical bar, docked left or right inside the **attached** view, +showing a newest-first, time-ordered series of individual file changes the agent +made — **one entry per edit**, a literal chronology rather than a commit list. +Each entry shows the file, the change magnitude, and a one-line "what"; an entry +can be expanded to a short inline diff peek, and clicking it opens the file in +the user's editor **at the changed line**. + +The bar is toggleable and left/right alignable, configurable both globally and +per repo. + +## Non-goals + +- Not a commit list and not derived from git history or the reflog. +- No new agent UI — wsx continues to delegate the agent TUI to the PTY; the bar + is wsx chrome carved out of the attach layout. +- No new persistent storage of change events in this iteration. The on-disk + session logs are the source of truth (Approach 1, below). An mtime-keyed + on-disk cache is a possible later optimization, explicitly out of scope now. + +## Decisions (from brainstorming) + +| Decision | Choice | +| --- | --- | +| Entry fidelity | **B (medium)** default: `time · file · +adds/-dels` + one-line summary. **C (inline diff peek)** on expand. | +| Click behavior | Open in editor at **`file:line`**, reusing today's editor-open mechanism plus a line capability. | +| Default width | **Wider (~32% of attach width)**; **min width configurable**. | +| Side | Left/right, configurable; default **right**. Toggleable on/off. | +| Settings scope | **Global + per-repo**, mirroring `detail_bar_config`. | +| History scope | **Whole workspace history** — all sessions on the branch/worktree, rebuilt from on-disk logs. | +| Agent coverage | **All agents** (Claude, Codex, Pi, Hermes). | +| Grouping | **One entry per edit** (literal time series), newest on top. | + +## Architecture (Approach 1: logs as source of truth) + +``` +session JSONL logs ──▶ ChangeEvent extraction ──▶ ChronologyTimeline ──▶ ChronologyBar + (Claude/Codex/Pi/ (per-agent parsers, (merge across all (carved column + Hermes, on disk) mutating tools only) sessions, cached) in attach view) +``` + +- The **active** session's log is followed live by the tail loop that already + runs in `src/activity/`. +- **Historical** sessions for the workspace are located and scanned once on + attach, then cached by file `(size, mtime)` so re-attach and periodic refresh + only re-read the active (growing) file. +- No SQLite table for events. Disabling the feature removes the bar with no + residue. + +## Components + +### 1. `ChangeEvent` extraction (parser layer) + +Extend the existing tool_use parsing in `src/activity/events.rs` (Claude) and +the `codex_events.rs` / `pi_events.rs` / `hermes_events.rs` variants to emit a +structured event for each **mutating** tool call. `Read` and other non-mutating +tools are excluded. + +```rust +pub struct ChangeEvent { + pub timestamp: u64, // parsed from the JSONL line (existing ms-precision parser) + pub tool: ChangeTool, // Edit | MultiEdit | Write | NotebookEdit (+ per-agent equivalents) + pub file_path: PathBuf, // stored absolute; displayed workspace-relative + pub summary: String, // one-line "what" (B fidelity) + pub detail: ChangeDetail, // bounded old/new text or content snippet (C expand) +} + +pub enum ChangeTool { Edit, MultiEdit, Write, NotebookEdit } + +pub enum ChangeDetail { + Edit { old: String, new: String }, // bounded slices for the peek + Write { head: String }, // leading lines of new content + None, // agent exposed no change text +} +``` + +**Summary heuristic** (`summary`): +- `Edit`/`MultiEdit`: the most salient changed line — prefer a line matching a + declaration pattern (`fn`/`def`/`class`/`pub`/`struct`/`impl`/assignment), + else the first non-blank changed line; trimmed and truncated to fit. +- `Write`: `"new file"` or the first declaration in the content. +- `NotebookEdit`: the cell's summary/first line. + +**Per-agent mapping:** each non-Claude parser maps its own tool vocabulary +(e.g. Codex `apply_patch`) into the same `ChangeTool` / `ChangeDetail`. Agents +that do not expose the changed text degrade to **B-only** entries +(`ChangeDetail::None`, no C-expand). + +This is additive to the existing parsers, which already extract `file_path` and +parse timestamps; the new work is retaining the change text and synthesizing the +summary. + +### 2. `ChronologyTimeline` (`src/activity/chronology.rs`, new) + +Responsibilities: +- **Locate** all session JSONL files for the workspace's worktree path, using + the same encoded-cwd convention as `pty::session::has_prior_session`, across + each agent's log directory. +- **Build** the merged timeline by parsing each file's `ChangeEvent`s and + merging by `timestamp`, stable, newest-first. +- **Cache** per file by `(size, mtime)`; only re-read changed/active files. The + active session file is normally the only one that grows between refreshes. +- Expose a view to the renderer that keeps all events but is rendered lazily by + scroll offset (no hard cap on retained history; the renderer paints a window). + +### 3. `ChronologyConfig` (`src/config/chronology.rs`, new) + +Mirror `src/config/detail_bar_config.rs` exactly: a global JSON blob in the +`settings` table plus a per-repo JSON override on a new `repos.chronology_config` +TEXT column (added via the established `ALTER TABLE repos ADD COLUMN` migration +pattern in `src/data/store.rs`). Scalar fields merge per-field; the per-repo +override wins per-field. + +```rust +pub struct ChronologyConfig { + pub visible: bool, // default true + pub side: Side, // Left | Right, default Right + pub width: WidthSpec, +} +pub struct WidthSpec { + pub percent: u8, // default ~32, of attach area width + pub min_cols: u16, // user-configurable minimum (the requested knob) + pub max_cols: u16, +} +pub enum Side { Left, Right } +``` + +Provide `Default`, `with_override`, and `sanitize` (clamp `percent`/`min_cols`/ +`max_cols` into legal ranges; swap inverted min/max), matching the +`detail_bar_config` and `usage_window` precedents. Resolution happens at render +time so CLI and in-app toggles agree live. Resolved width = `percent` of attach +width, clamped to `[min_cols, max_cols]`. + +### 4. `ChronologyBar` renderer (in `src/ui/attached.rs`) + +When the chronology is visible, before computing pane rects: +1. Split the attach pane area horizontally: carve a `width`-column strip on the + configured side; the remainder feeds the existing pane/split layout + unchanged. +2. Draw a 1-column divider reusing the `src/ui/split.rs` divider style. +3. Paint the bar: + - Header: `CHANGE CHRONOLOGY` with a side indicator. + - Newest-first entries: line 1 `time · file · +adds/-dels`; line 2 the dim + one-line summary (B). The focused/expanded entry additionally renders the + short inline diff peek (C). + - Long file paths middle-truncated; scrollable via an offset. + +**Auto-hide:** if the attach area is too narrow to host `min_cols` plus a usable +agent width, the bar is skipped for that frame without breaking the agent pane. + +Hit-testing returns per-entry clickable rects via the existing +`PanesDrawOutput` pattern (alongside `chip_rects` / `pane_rects`). + +### 5. Editor `file:line` extension (`src/commands/external.rs`) + +Add `{file}` and `{line}` placeholders to the editor command template +substitution (alongside today's `{path}`), and a new entry point: + +```rust +pub fn open_in_editor_at(worktree: &Path, file: &Path, line: u32, configured: Option<&str>) -> Result<()>; +``` + +When the configured template contains no placeholders, fall back by detecting +common editors and formatting their goto syntax: +- `code` / VS Code: `code --goto {file}:{line}` +- `vim` / `nvim` / `vi`: `+{line} {file}` +- `emacs` / `emacsclient`: `+{line} {file}` +- otherwise: append `{file}` (line omitted). + +Today's `e` (dashboard) and `Ctrl-x e` (attached) whole-worktree open are +unchanged; this is a new, separate call path used only by chronology entry +clicks. + +**Line resolution** (computed lazily on click, not stored): +- `Edit`/`MultiEdit`: locate the first line of `old_string` in the file's + *current* contents → 1-based line number. +- `Write` / new file: line 1. +- Not found / file missing: fall back to line 1; a spawn failure surfaces via + the existing editor spawn-error path. + +## Interaction + +| Action | Binding | +| --- | --- | +| Toggle bar on/off | `Ctrl-x c` (attached leader; also via config) | +| Swap side (L/R) | `Ctrl-x C` | +| Scroll | mouse wheel over the bar; keys when the bar holds focus | +| Expand entry to C (diff peek) | click entry (or key) toggles inline peek | +| Open in editor at line | click an entry's file → `open_in_editor_at(file, line)` | + +`Ctrl-x` is the existing attached-mode leader, which already accepts letter +follow-ups (`d`, `e`, `u`, `a`, `x`, arrows); `c`/`C` are free. + +## CLI surface + +Following the `usage_graph_window` and detail-bar precedents: +- `wsx config set chronology ` (global) and the per-repo equivalent. +- Discrete conveniences if they fit the existing config CLI shape: + `chronology.visible`, `chronology.side`, `chronology.width.min_cols`. + +Reads resolve live at render time so CLI changes and the in-app toggle stay in +sync. + +## Error handling & edge cases + +- Missing/unreadable log dir → empty timeline; bar shows an em-dash placeholder + (like `recent_files`). +- Malformed JSONL lines are skipped (existing parser behavior). +- Non-Claude agents lacking change text → B-only entries (no expand). +- Deleted/renamed files → click attempts open at line 1; if the file is gone, + the editor spawn error surfaces via the existing path. +- Terminal too narrow → bar auto-hides without disturbing the agent pane. +- Huge histories → bounded per-file cache + lazy windowed render. If cold scan + is ever too slow, an mtime-keyed on-disk cache is a later add (out of scope). + +## Testing + +- `ChangeEvent` extraction per tool and per agent — table-driven over sample + JSONL lines, mirroring the existing `events.rs` tests. +- `summary` heuristic across Edit/Write/NotebookEdit inputs. +- `ChronologyTimeline` merge ordering and cache invalidation on `(size, mtime)` + change. +- `ChronologyConfig` default/override/sanitize round-trips, mirroring the + `usage_window` tests. +- Editor `{file}`/`{line}` argv resolution including each fallback editor — + extending the existing `resolve_argv` tests. +- Line-number resolution from `old_string` (found, not-found, new-file cases). +- Renderer width/auto-hide math as pure functions where feasible. + +## Files touched + +- `src/activity/events.rs`, `codex_events.rs`, `pi_events.rs`, `hermes_events.rs` + — emit `ChangeEvent`s. +- `src/activity/chronology.rs` (new) — timeline build/merge/cache. +- `src/config/chronology.rs` (new) + `src/config/mod.rs` — config struct. +- `src/data/store.rs` — `repos.chronology_config` column + accessors. +- `src/ui/attached.rs` — carve column, render bar, hit-testing. +- `src/app/input.rs` — `Ctrl-x c` / `Ctrl-x C`, scroll, click → editor. +- `src/commands/external.rs` — `open_in_editor_at` + `{file}`/`{line}`. +- `src/cli.rs` — `chronology` config subcommands. +- `README.md` — document the feature, keybindings, and config. From 2ea785ab1140fb46434802b062c8f4e363a10faf Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 14:45:44 -0400 Subject: [PATCH 02/69] docs: implementation plan for change chronology view --- .../2026-06-05-change-chronology-view.md | 1974 +++++++++++++++++ 1 file changed, 1974 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-change-chronology-view.md diff --git a/docs/superpowers/plans/2026-06-05-change-chronology-view.md b/docs/superpowers/plans/2026-06-05-change-chronology-view.md new file mode 100644 index 0000000..2ee3e1b --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-change-chronology-view.md @@ -0,0 +1,1974 @@ +# Change Chronology View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a toggleable vertical bar in wsx's attached view showing a newest-first, time-ordered chronology of individual file changes the agent made, expandable to a diff peek and clickable to open the editor at the changed line. + +**Architecture:** Approach 1 — the on-disk agent session JSONL logs are the source of truth. A standalone module scans all of a workspace's session logs, extracts a `ChangeEvent` per mutating tool call, merges them into a cached, newest-first timeline, and the attached view carves a configurable side column to render it. No new events table; config mirrors the existing `detail_bar_config` global-blob + per-repo-override pattern. + +**Tech Stack:** Rust, `ratatui` (TUI), `rusqlite` (settings/repos), `serde_json` (JSONL parsing), `shlex` (editor command parsing). Tests are standard `#[cfg(test)]` unit tests run with `cargo test`. + +**Design note on decomposition:** The brainstorming spec listed "extend the existing parsers" under files touched. During planning this is refined to a **standalone extractor** in `src/activity/chronology.rs`: whole-history requires scanning *all* session files (not just the live-tailed active one), so a self-contained scanner that re-uses the existing public helpers (`encode_cwd`, `parse_iso8601_ms`) is lower-risk than threading a new event type through four live tail loops. Behavior matches the spec; only the realization differs. + +**Agent sequencing:** Claude is implemented end-to-end first (richest, fully-specified JSONL). Codex/Pi/Hermes extraction are separate, clearly-bounded tasks (Phase 8) that reuse the shared `ChangeEvent` types and each parser's existing file-path extraction. Until those land, non-Claude agents simply show an empty chronology (em-dash placeholder) — no crash, no fabricated data. + +--- + +## File Structure + +- `src/commands/external.rs` (modify) — add `{file}`/`{line}` placeholders + `open_in_editor_at`. +- `src/activity/chronology.rs` (create) — `ChangeEvent`/`ChangeTool`/`ChangeDetail` types, Claude extraction, summary heuristic, line resolution, session-file enumeration, timeline build/merge + `(size, mtime)` cache. +- `src/activity/mod.rs` (modify) — `pub mod chronology;`. +- `src/config/chronology.rs` (create) — `ChronologyConfig`/`WidthSpec`/`Side`, `Default`/`with_override`/`sanitize`, `resolve_global_only`/`resolve`. +- `src/config/mod.rs` (modify) — `pub mod chronology;`. +- `src/data/store.rs` (modify) — `repos.chronology_config` column + migration + `Repo` field + `set_repo_chronology_config`. +- `src/cli.rs` (modify) — `chronology_config` config key (validate/normalize/seed) + valid-keys list. +- `src/ui/chronology_bar.rs` (create) — pure rendering helpers (entry → `Vec`, width math, auto-hide). +- `src/ui/attached.rs` (modify) — carve the side column, draw the bar + divider, return per-entry click rects. +- `src/ui/mod.rs` (modify) — `pub mod chronology_bar;`. +- `src/app/input.rs` (modify) — `Ctrl-x c` (toggle), `Ctrl-x C` (swap side), scroll, click → expand/open. +- `src/app/render.rs` / `src/app.rs` (modify) — hold timeline + bar UI state, resolve config, pass to renderer. +- `README.md` (modify) — document feature, keybindings, config. + +--- + +## Phase 1 — Editor open at file:line + +### Task 1: `{file}`/`{line}` placeholders + `open_in_editor_at` + +**Files:** +- Modify: `src/commands/external.rs` +- Test: same file's `#[cfg(test)] mod tests` + +- [ ] **Step 1: Write the failing tests** + +Add to the `tests` module in `src/commands/external.rs`: + +```rust +#[test] +fn editor_at_substitutes_file_and_line_placeholders() { + let argv = resolve_editor_at_argv( + "code --goto {file}:{line}", + "/tmp/wt/src/main.rs", + 42, + ) + .unwrap(); + assert_eq!(argv, vec!["code", "--goto", "/tmp/wt/src/main.rs:42"]); +} + +#[test] +fn editor_at_vim_fallback_uses_plus_line() { + let argv = resolve_editor_at_argv("nvim", "/tmp/wt/src/main.rs", 42).unwrap(); + assert_eq!(argv, vec!["nvim", "+42", "/tmp/wt/src/main.rs"]); +} + +#[test] +fn editor_at_code_fallback_uses_goto() { + let argv = resolve_editor_at_argv("code", "/tmp/wt/src/main.rs", 7).unwrap(); + assert_eq!(argv, vec!["code", "--goto", "/tmp/wt/src/main.rs:7"]); +} + +#[test] +fn editor_at_emacs_fallback_uses_plus_line() { + let argv = resolve_editor_at_argv("emacsclient", "/tmp/wt/a.rs", 3).unwrap(); + assert_eq!(argv, vec!["emacsclient", "+3", "/tmp/wt/a.rs"]); +} + +#[test] +fn editor_at_unknown_editor_appends_file_only() { + let argv = resolve_editor_at_argv("myeditor", "/tmp/wt/a.rs", 3).unwrap(); + assert_eq!(argv, vec!["myeditor", "/tmp/wt/a.rs"]); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test --lib editor_at_` +Expected: FAIL — `cannot find function resolve_editor_at_argv`. + +- [ ] **Step 3: Implement the resolver** + +Add to `src/commands/external.rs` (non-test scope): + +```rust +/// Resolve the editor command into argv that opens `file` at `line`. +/// +/// If the template contains `{file}`/`{line}` placeholders, substitute them. +/// Otherwise fall back to the goto convention of common editors detected from +/// the program name: VS Code (`code --goto file:line`), vim/nvim/vi +/// (`+line file`), emacs/emacsclient (`+line file`); any other editor gets the +/// file appended with the line omitted. +fn resolve_editor_at_argv(cmd: &str, file: &str, line: u32) -> Result> { + let line_s = line.to_string(); + let mut parts = shlex::split(cmd) + .ok_or_else(|| Error::UserInput(format!("could not parse command: {cmd}")))?; + if parts.is_empty() { + return Err(Error::UserInput("command is empty".into())); + } + let used_placeholder = parts + .iter() + .any(|p| p.contains("{file}") || p.contains("{line}")); + if used_placeholder { + for part in &mut parts { + *part = part.replace("{file}", file).replace("{line}", &line_s); + } + return Ok(parts); + } + let prog = std::path::Path::new(&parts[0]) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(&parts[0]) + .to_string(); + match prog.as_str() { + "code" | "codium" | "cursor" => parts.push(format!("{file}:{line_s}")), + "vim" | "nvim" | "vi" | "emacs" | "emacsclient" => { + parts.push(format!("+{line_s}")); + parts.push(file.to_string()); + } + _ => parts.push(file.to_string()), + } + Ok(parts) +} + +/// Resolve and launch the user's editor on `file`, positioned at `line`. +/// Spawns with cwd = `worktree`. Used by the chronology bar's entry clicks. +pub fn open_in_editor_at( + worktree: &Path, + file: &Path, + line: u32, + configured: Option<&str>, +) -> Result<()> { + let cmd = resolve_editor_cmd(configured)?; + let file_str = file.to_string_lossy(); + let mut parts = resolve_editor_at_argv(&cmd, file_str.as_ref(), line)?; + let program = parts.remove(0); + let mut command = std::process::Command::new(&program); + command.args(&parts).current_dir(worktree); + detach_io(&mut command); + command + .spawn() + .map_err(|e| Error::UserInput(format!("spawn {program}: {e}")))?; + Ok(()) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test --lib editor_at_` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/external.rs +git commit -m "feat(editor): open_in_editor_at with {file}/{line} placeholders and goto fallbacks" +``` + +--- + +## Phase 2 — ChangeEvent types, extraction, summary, line resolution + +### Task 2: Shared types + module wiring + +**Files:** +- Create: `src/activity/chronology.rs` +- Modify: `src/activity/mod.rs` + +- [ ] **Step 1: Create the module with types** + +Create `src/activity/chronology.rs`: + +```rust +//! Change Chronology: a newest-first, time-ordered series of individual file +//! changes the agent made, rebuilt from the on-disk session JSONL logs. +//! +//! The agent session logs are the source of truth (see +//! `docs/superpowers/specs/2026-06-05-change-chronology-view-design.md`). +//! This module scans ALL of a workspace's session files (not just the +//! live-tailed active one), extracts one `ChangeEvent` per mutating tool call, +//! and merges them into a timeline cached by each file's `(size, mtime)`. + +use crate::activity::events::{encode_cwd, parse_iso8601_ms}; +use std::path::{Path, PathBuf}; + +/// The mutating tool that produced a change. Read and non-mutating tools are +/// never recorded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChangeTool { + Edit, + MultiEdit, + Write, + NotebookEdit, +} + +impl ChangeTool { + /// Compact label for display (`edit` / `write` / …). + pub fn label(self) -> &'static str { + match self { + ChangeTool::Edit => "edit", + ChangeTool::MultiEdit => "edit", + ChangeTool::Write => "write", + ChangeTool::NotebookEdit => "edit", + } + } +} + +/// Bounded change text retained for the expandable diff peek (C fidelity). +/// `None` when the agent did not expose the changed text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChangeDetail { + Edit { old: String, new: String }, + Write { head: String }, + None, +} + +/// One change the agent made at one moment in time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangeEvent { + /// Epoch milliseconds, parsed from the JSONL line's `timestamp`. + pub timestamp_ms: i64, + pub tool: ChangeTool, + /// Absolute path as the agent reported it (display layer makes it relative). + pub file_path: PathBuf, + /// One-line "what" summary (B fidelity). + pub summary: String, + /// Change text for the C-expand peek. + pub detail: ChangeDetail, +} +``` + +- [ ] **Step 2: Wire the module** + +In `src/activity/mod.rs`, add after the existing `pub mod` lines: + +```rust +pub mod chronology; +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build` +Expected: builds clean (unused-code warnings are acceptable at this stage). + +- [ ] **Step 4: Commit** + +```bash +git add src/activity/chronology.rs src/activity/mod.rs +git commit -m "feat(chronology): add ChangeEvent/ChangeTool/ChangeDetail types" +``` + +### Task 3: Summary heuristic + +**Files:** +- Modify: `src/activity/chronology.rs` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/activity/chronology.rs`: + +```rust +#[cfg(test)] +mod summary_tests { + use super::*; + + #[test] + fn prefers_declaration_line() { + let s = summarize_edit("let x = 1;\n", "let x = 1;\npub fn foo() {}\n"); + assert_eq!(s, "pub fn foo() {}"); + } + + #[test] + fn falls_back_to_first_nonblank_changed_line() { + let s = summarize_edit("a = 1\n", "a = 2\n"); + assert_eq!(s, "a = 2"); + } + + #[test] + fn write_new_file_when_no_decl() { + let s = summarize_write("plain text\nmore text\n"); + assert_eq!(s, "new file"); + } + + #[test] + fn write_uses_first_declaration_when_present() { + let s = summarize_write("# header\nclass Thing:\n pass\n"); + assert_eq!(s, "class Thing:"); + } + + #[test] + fn truncates_long_summaries() { + let long = "x".repeat(200); + let s = summarize_edit("", &format!("{long}\n")); + assert!(s.chars().count() <= SUMMARY_MAX_CHARS); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib summary_tests` +Expected: FAIL — `cannot find function summarize_edit`. + +- [ ] **Step 3: Implement the heuristic** + +Add to `src/activity/chronology.rs` (non-test scope): + +```rust +pub(crate) const SUMMARY_MAX_CHARS: usize = 80; + +/// True if a line looks like a declaration worth surfacing. +fn looks_like_decl(line: &str) -> bool { + let t = line.trim_start(); + const KW: [&str; 11] = [ + "fn ", "pub ", "def ", "class ", "struct ", "impl ", "enum ", "trait ", + "func ", "type ", "const ", + ]; + KW.iter().any(|k| t.starts_with(k)) +} + +fn truncate_summary(s: &str) -> String { + let trimmed = s.trim(); + if trimmed.chars().count() <= SUMMARY_MAX_CHARS { + return trimmed.to_string(); + } + let mut out: String = trimmed.chars().take(SUMMARY_MAX_CHARS - 1).collect(); + out.push('…'); + out +} + +/// Summarize an Edit/MultiEdit: prefer a declaration among lines present in +/// `new` but not `old`; else the first non-blank line of `new` not in `old`; +/// else the first non-blank line of `new`. +pub(crate) fn summarize_edit(old: &str, new: &str) -> String { + let old_lines: std::collections::HashSet<&str> = old.lines().collect(); + let changed: Vec<&str> = new + .lines() + .filter(|l| !old_lines.contains(*l) && !l.trim().is_empty()) + .collect(); + if let Some(decl) = changed.iter().find(|l| looks_like_decl(l)) { + return truncate_summary(decl); + } + if let Some(first) = changed.first() { + return truncate_summary(first); + } + match new.lines().find(|l| !l.trim().is_empty()) { + Some(l) => truncate_summary(l), + None => "edit".to_string(), + } +} + +/// Summarize a Write: the first declaration in the content, else "new file". +pub(crate) fn summarize_write(content: &str) -> String { + match content.lines().find(|l| looks_like_decl(l)) { + Some(decl) => truncate_summary(decl), + None => "new file".to_string(), + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib summary_tests` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/activity/chronology.rs +git commit -m "feat(chronology): summary heuristic for Edit/Write changes" +``` + +### Task 4: Extract ChangeEvents from a Claude JSONL line + +**Files:** +- Modify: `src/activity/chronology.rs` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/activity/chronology.rs`: + +```rust +#[cfg(test)] +mod extract_tests { + use super::*; + + fn line(json: &str) -> serde_json::Value { + serde_json::from_str(json).unwrap() + } + + #[test] + fn extracts_edit_event() { + let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"/wt/a.rs","old_string":"let x=1;","new_string":"pub fn foo() {}"}}]}}"#); + let evs = extract_change_events(&v); + assert_eq!(evs.len(), 1); + assert_eq!(evs[0].tool, ChangeTool::Edit); + assert_eq!(evs[0].file_path, std::path::PathBuf::from("/wt/a.rs")); + assert_eq!(evs[0].summary, "pub fn foo() {}"); + assert!(matches!(evs[0].detail, ChangeDetail::Edit { .. })); + assert_eq!(evs[0].timestamp_ms, parse_iso8601_ms("2026-05-14T17:32:02.744Z").unwrap()); + } + + #[test] + fn extracts_write_event() { + let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t2","name":"Write","input":{"file_path":"/wt/new.rs","content":"pub struct Z;"}}]}}"#); + let evs = extract_change_events(&v); + assert_eq!(evs.len(), 1); + assert_eq!(evs[0].tool, ChangeTool::Write); + assert_eq!(evs[0].summary, "pub struct Z;"); + assert!(matches!(&evs[0].detail, ChangeDetail::Write { head } if head.contains("struct Z"))); + } + + #[test] + fn multiedit_emits_one_event_per_edit() { + let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t3","name":"MultiEdit","input":{"file_path":"/wt/a.rs","edits":[{"old_string":"a","new_string":"pub fn one(){}"},{"old_string":"b","new_string":"pub fn two(){}"}]}}]}}"#); + let evs = extract_change_events(&v); + assert_eq!(evs.len(), 2); + assert_eq!(evs[0].tool, ChangeTool::MultiEdit); + assert_eq!(evs[1].summary, "pub fn two(){}"); + } + + #[test] + fn ignores_read_and_bash() { + let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t4","name":"Read","input":{"file_path":"/wt/a.rs"}},{"type":"tool_use","id":"t5","name":"Bash","input":{"command":"ls"}}]}}"#); + assert!(extract_change_events(&v).is_empty()); + } + + #[test] + fn ignores_non_assistant_lines() { + let v = line(r#"{"type":"user","timestamp":"2026-05-14T17:32:02.744Z","message":{"role":"user","content":"hi"}}"#); + assert!(extract_change_events(&v).is_empty()); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib extract_tests` +Expected: FAIL — `cannot find function extract_change_events`. + +- [ ] **Step 3: Implement extraction** + +Add to `src/activity/chronology.rs` (non-test scope): + +```rust +/// Bounded number of characters retained per side of a diff peek. +const DETAIL_MAX_CHARS: usize = 600; + +fn clip(s: &str) -> String { + s.chars().take(DETAIL_MAX_CHARS).collect() +} + +fn tool_from_name(name: &str) -> Option { + match name { + "Edit" => Some(ChangeTool::Edit), + "MultiEdit" => Some(ChangeTool::MultiEdit), + "Write" => Some(ChangeTool::Write), + "NotebookEdit" => Some(ChangeTool::NotebookEdit), + _ => None, + } +} + +/// Extract zero or more `ChangeEvent`s from one parsed Claude JSONL line. +/// Only `type == "assistant"` lines with mutating `tool_use` blocks produce +/// events. A `MultiEdit` produces one event per element of its `edits` array. +pub fn extract_change_events(v: &serde_json::Value) -> Vec { + let mut out = Vec::new(); + if v.get("type").and_then(|t| t.as_str()) != Some("assistant") { + return out; + } + let ts = v + .get("timestamp") + .and_then(|t| t.as_str()) + .and_then(parse_iso8601_ms) + .unwrap_or(0); + let Some(blocks) = v + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + else { + return out; + }; + for block in blocks { + if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") { + continue; + } + let name = block.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let Some(tool) = tool_from_name(name) else { + continue; + }; + let input = block.get("input").unwrap_or(&serde_json::Value::Null); + let file = input + .get("file_path") + .or_else(|| input.get("notebook_path")) + .and_then(|p| p.as_str()); + let Some(file) = file else { continue }; + let file_path = PathBuf::from(file); + + match tool { + ChangeTool::Write => { + let content = input.get("content").and_then(|c| c.as_str()).unwrap_or(""); + out.push(ChangeEvent { + timestamp_ms: ts, + tool, + file_path, + summary: summarize_write(content), + detail: ChangeDetail::Write { head: clip(content) }, + }); + } + ChangeTool::MultiEdit => { + let edits = input.get("edits").and_then(|e| e.as_array()); + if let Some(edits) = edits { + for e in edits { + let old = e.get("old_string").and_then(|s| s.as_str()).unwrap_or(""); + let new = e.get("new_string").and_then(|s| s.as_str()).unwrap_or(""); + out.push(ChangeEvent { + timestamp_ms: ts, + tool, + file_path: file_path.clone(), + summary: summarize_edit(old, new), + detail: ChangeDetail::Edit { old: clip(old), new: clip(new) }, + }); + } + } + } + ChangeTool::Edit | ChangeTool::NotebookEdit => { + let old = input + .get("old_string") + .and_then(|s| s.as_str()) + .unwrap_or(""); + let new = input + .get("new_string") + .or_else(|| input.get("new_source")) + .and_then(|s| s.as_str()) + .unwrap_or(""); + out.push(ChangeEvent { + timestamp_ms: ts, + tool, + file_path, + summary: summarize_edit(old, new), + detail: ChangeDetail::Edit { old: clip(old), new: clip(new) }, + }); + } + } + } + out +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib extract_tests` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/activity/chronology.rs +git commit -m "feat(chronology): extract ChangeEvents from Claude JSONL lines" +``` + +### Task 5: Resolve the changed line within the current file + +**Files:** +- Modify: `src/activity/chronology.rs` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/activity/chronology.rs`: + +```rust +#[cfg(test)] +mod line_tests { + use super::*; + + #[test] + fn finds_line_of_old_string_first_line() { + let file = "fn a() {}\nfn b() {}\nfn c() {}\n"; + let detail = ChangeDetail::Edit { old: "fn b() {}".into(), new: "fn b2() {}".into() }; + assert_eq!(resolve_line(file, &detail), 2); + } + + #[test] + fn write_resolves_to_line_one() { + let detail = ChangeDetail::Write { head: "anything".into() }; + assert_eq!(resolve_line("whatever\n", &detail), 1); + } + + #[test] + fn missing_old_string_falls_back_to_line_one() { + let detail = ChangeDetail::Edit { old: "nonexistent".into(), new: "x".into() }; + assert_eq!(resolve_line("fn a() {}\n", &detail), 1); + } + + #[test] + fn none_detail_falls_back_to_line_one() { + assert_eq!(resolve_line("fn a() {}\n", &ChangeDetail::None), 1); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib line_tests` +Expected: FAIL — `cannot find function resolve_line`. + +- [ ] **Step 3: Implement line resolution** + +Add to `src/activity/chronology.rs` (non-test scope): + +```rust +/// 1-based line to open the editor at, given the file's current contents and +/// the change detail. For an Edit, locate the first line of `old` in `contents`; +/// for a Write (or anything not found), line 1. +pub fn resolve_line(contents: &str, detail: &ChangeDetail) -> u32 { + let needle = match detail { + ChangeDetail::Edit { old, .. } => old.lines().find(|l| !l.trim().is_empty()), + _ => None, + }; + let Some(needle) = needle else { return 1 }; + for (i, line) in contents.lines().enumerate() { + if line.contains(needle) { + return (i + 1) as u32; + } + } + 1 +} + +/// Read the file at `path` and resolve the line for `detail`. Returns 1 when +/// the file can't be read (deleted/renamed since the edit). +pub fn resolve_line_in_file(path: &Path, detail: &ChangeDetail) -> u32 { + match std::fs::read_to_string(path) { + Ok(contents) => resolve_line(&contents, detail), + Err(_) => 1, + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib line_tests` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/activity/chronology.rs +git commit -m "feat(chronology): resolve editor line from old_string in current file" +``` + +--- + +## Phase 3 — Timeline build, merge, and cache + +### Task 6: Enumerate all session files for a worktree + +**Files:** +- Modify: `src/activity/chronology.rs` + +- [ ] **Step 1: Write the failing test** + +Append to `src/activity/chronology.rs`: + +```rust +#[cfg(test)] +mod locate_tests { + use super::*; + use std::io::Write; + + #[test] + fn lists_all_jsonl_files_in_session_dir() { + let home = tempfile::TempDir::new().unwrap(); + let work = tempfile::TempDir::new().unwrap(); + let abs = std::fs::canonicalize(work.path()).unwrap(); + let dir = home.path().join(".claude/projects").join(encode_cwd(&abs)); + std::fs::create_dir_all(&dir).unwrap(); + for name in ["a.jsonl", "b.jsonl", "notes.txt"] { + let mut f = std::fs::File::create(dir.join(name)).unwrap(); + writeln!(f, "{{}}").unwrap(); + } + let files = session_files_in(home.path(), &abs); + assert_eq!(files.len(), 2, "only .jsonl files counted"); + } + + #[test] + fn missing_dir_returns_empty() { + let home = tempfile::TempDir::new().unwrap(); + let abs = std::path::PathBuf::from("/nonexistent/worktree"); + assert!(session_files_in(home.path(), &abs).is_empty()); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib locate_tests` +Expected: FAIL — `cannot find function session_files_in`. + +- [ ] **Step 3: Implement enumeration** + +Add to `src/activity/chronology.rs` (non-test scope). `session_files_in` is testable (explicit home); `claude_session_files` is the production entry point that uses the real home dir: + +```rust +/// All `.jsonl` session files under `/.claude/projects//`. +/// Testable variant taking an explicit home dir and canonical worktree path. +pub(crate) fn session_files_in(home: &Path, abs_worktree: &Path) -> Vec { + let dir = home + .join(".claude/projects") + .join(encode_cwd(abs_worktree)); + let mut files = Vec::new(); + let Ok(rd) = std::fs::read_dir(&dir) else { + return files; + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + files.push(path); + } + } + files +} + +/// Production entry point: resolve the real home dir and canonical worktree. +pub fn claude_session_files(worktree: &Path) -> Vec { + let Some(home) = dirs::home_dir() else { + return Vec::new(); + }; + let Ok(abs) = std::fs::canonicalize(worktree) else { + return Vec::new(); + }; + session_files_in(&home, &abs) +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib locate_tests` +Expected: PASS (2 tests). (`tempfile` is already a dev-dependency — used throughout `events.rs` tests.) + +- [ ] **Step 5: Commit** + +```bash +git add src/activity/chronology.rs +git commit -m "feat(chronology): enumerate all session jsonl files for a worktree" +``` + +### Task 7: Parse a file into ChangeEvents + +**Files:** +- Modify: `src/activity/chronology.rs` + +- [ ] **Step 1: Write the failing test** + +Append to `src/activity/chronology.rs`: + +```rust +#[cfg(test)] +mod parse_file_tests { + use super::*; + use std::io::Write; + + #[test] + fn parses_events_from_a_jsonl_file_skipping_garbage() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("s.jsonl"); + let mut f = std::fs::File::create(&path).unwrap(); + writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-14T17:00:00.000Z","message":{{"content":[{{"type":"tool_use","name":"Write","input":{{"file_path":"/wt/x.rs","content":"pub fn x(){{}}"}}}}]}}}}"#).unwrap(); + writeln!(f, "not json at all").unwrap(); + writeln!(f, r#"{{"type":"user","message":{{"content":"hi"}}}}"#).unwrap(); + let evs = parse_file(&path); + assert_eq!(evs.len(), 1); + assert_eq!(evs[0].tool, ChangeTool::Write); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib parse_file_tests` +Expected: FAIL — `cannot find function parse_file`. + +- [ ] **Step 3: Implement file parsing** + +Add to `src/activity/chronology.rs` (non-test scope): + +```rust +/// Parse every line of a session file into `ChangeEvent`s. Malformed lines are +/// skipped silently (matches the existing tail-loop tolerance). +pub fn parse_file(path: &Path) -> Vec { + use std::io::{BufRead, BufReader}; + let Ok(file) = std::fs::File::open(path) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for line in BufReader::new(file).lines().map_while(|l| l.ok()) { + if line.trim().is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(&line) { + out.extend(extract_change_events(&v)); + } + } + out +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib parse_file_tests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/activity/chronology.rs +git commit -m "feat(chronology): parse a session file into ChangeEvents" +``` + +### Task 8: Cached timeline (merge across files, invalidate on size/mtime) + +**Files:** +- Modify: `src/activity/chronology.rs` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/activity/chronology.rs`: + +```rust +#[cfg(test)] +mod timeline_tests { + use super::*; + use std::io::Write; + + fn write_event(path: &Path, ts: &str, file: &str) { + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!( + f, + r#"{{"type":"assistant","timestamp":"{ts}","message":{{"content":[{{"type":"tool_use","name":"Write","input":{{"file_path":"{file}","content":"x"}}}}]}}}}"# + ) + .unwrap(); + } + + #[test] + fn merges_files_newest_first() { + let dir = tempfile::TempDir::new().unwrap(); + let a = dir.path().join("a.jsonl"); + let b = dir.path().join("b.jsonl"); + write_event(&a, "2026-05-14T17:00:00.000Z", "/wt/old.rs"); + write_event(&b, "2026-05-14T18:00:00.000Z", "/wt/new.rs"); + let mut tl = Timeline::default(); + tl.refresh(&[a.clone(), b.clone()]); + let evs = tl.events(); + assert_eq!(evs.len(), 2); + assert_eq!(evs[0].file_path, PathBuf::from("/wt/new.rs"), "newest first"); + assert_eq!(evs[1].file_path, PathBuf::from("/wt/old.rs")); + } + + #[test] + fn unchanged_file_is_not_reparsed() { + let dir = tempfile::TempDir::new().unwrap(); + let a = dir.path().join("a.jsonl"); + write_event(&a, "2026-05-14T17:00:00.000Z", "/wt/old.rs"); + let mut tl = Timeline::default(); + tl.refresh(&[a.clone()]); + assert_eq!(tl.parse_count(), 1); + tl.refresh(&[a.clone()]); // same size+mtime → cache hit + assert_eq!(tl.parse_count(), 1, "should not reparse unchanged file"); + } + + #[test] + fn grown_file_is_reparsed() { + let dir = tempfile::TempDir::new().unwrap(); + let a = dir.path().join("a.jsonl"); + write_event(&a, "2026-05-14T17:00:00.000Z", "/wt/old.rs"); + let mut tl = Timeline::default(); + tl.refresh(&[a.clone()]); + write_event(&a, "2026-05-14T19:00:00.000Z", "/wt/newer.rs"); + tl.refresh(&[a.clone()]); + assert_eq!(tl.parse_count(), 2, "size changed → reparse"); + assert_eq!(tl.events().len(), 2); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib timeline_tests` +Expected: FAIL — `cannot find type Timeline`. + +- [ ] **Step 3: Implement the cached timeline** + +Add to `src/activity/chronology.rs` (non-test scope): + +```rust +use std::collections::HashMap; +use std::time::SystemTime; + +/// A per-file cache key. Reparse only when size or mtime changes. +#[derive(Debug, Clone, PartialEq, Eq)] +struct FileStamp { + size: u64, + mtime: SystemTime, +} + +fn stamp(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + Some(FileStamp { + size: meta.len(), + mtime: meta.modified().ok()?, + }) +} + +/// Merged, newest-first chronology of `ChangeEvent`s across a workspace's +/// session files. Caches parsed events per file by `(size, mtime)`. +#[derive(Debug, Default)] +pub struct Timeline { + /// Per-file parsed events + the stamp they were parsed at. + per_file: HashMap)>, + /// Flattened, sorted view rebuilt on each refresh. + merged: Vec, + /// Test/diagnostic counter of how many file parses have occurred. + parses: usize, +} + +impl Timeline { + /// Re-scan `files`, reparsing only those whose `(size, mtime)` changed, + /// dropping cache entries for files no longer present, then rebuild the + /// merged newest-first view. + pub fn refresh(&mut self, files: &[PathBuf]) { + let present: std::collections::HashSet<&PathBuf> = files.iter().collect(); + self.per_file.retain(|p, _| present.contains(p)); + + for path in files { + let Some(st) = stamp(path) else { continue }; + let needs = match self.per_file.get(path) { + Some((prev, _)) => *prev != st, + None => true, + }; + if needs { + let evs = parse_file(path); + self.parses += 1; + self.per_file.insert(path.clone(), (st, evs)); + } + } + + let mut merged: Vec = self + .per_file + .values() + .flat_map(|(_, evs)| evs.iter().cloned()) + .collect(); + // Newest first; stable so same-timestamp events keep file order. + merged.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms)); + self.merged = merged; + } + + /// The merged newest-first events. + pub fn events(&self) -> &[ChangeEvent] { + &self.merged + } + + #[cfg(test)] + pub fn parse_count(&self) -> usize { + self.parses + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib timeline_tests` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/activity/chronology.rs +git commit -m "feat(chronology): cached newest-first Timeline merging session files" +``` + +--- + +## Phase 4 — Config (global + per-repo) and storage + +### Task 9: ChronologyConfig with default/override/sanitize + +**Files:** +- Create: `src/config/chronology.rs` +- Modify: `src/config/mod.rs` + +- [ ] **Step 1: Write the failing tests** + +Create `src/config/chronology.rs` with the test module first (implementation added in Step 3): + +```rust +//! Display config for the change-chronology bar. Resolved from a global JSON +//! blob in `settings` (`chronology_config`) + a per-repo JSON override on +//! `repos.chronology_config`. Scalar fields merge per-field; repo wins. +//! Mirrors `src/config/detail_bar_config.rs`. +//! +//! See `docs/superpowers/specs/2026-06-05-change-chronology-view-design.md`. + +use crate::data::store::{Repo, Store}; +use serde::{Deserialize, Serialize}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_visible_right_sane_width() { + let c = ChronologyConfig::default(); + assert!(c.visible); + assert_eq!(c.side, Side::Right); + assert_eq!(c.width.percent, 32); + assert!(c.width.min_cols <= c.width.max_cols); + } + + #[test] + fn override_merges_per_field() { + let base = ChronologyConfig::default(); + let ovr = ChronologyOverride { + visible: Some(false), + side: Some(Side::Left), + width: None, + }; + let merged = base.with_override(&ovr); + assert!(!merged.visible); + assert_eq!(merged.side, Side::Left); + assert_eq!(merged.width.percent, 32, "unspecified width inherits"); + } + + #[test] + fn sanitize_clamps_and_swaps() { + let mut c = ChronologyConfig::default(); + c.width.percent = 99; + c.width.min_cols = 80; + c.width.max_cols = 10; + c.sanitize(); + assert!(c.width.percent <= 80); + assert!(c.width.min_cols <= c.width.max_cols, "inverted min/max swapped"); + } + + #[test] + fn resolved_width_clamps_to_min_and_max() { + let mut c = ChronologyConfig::default(); + c.width.percent = 50; + c.width.min_cols = 20; + c.width.max_cols = 30; + assert_eq!(c.resolved_width(200), 30, "50% of 200 = 100, clamped to max 30"); + assert_eq!(c.resolved_width(20), 20, "50% of 20 = 10, clamped to min 20"); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib config::chronology` +Expected: FAIL — `ChronologyConfig` not found. (Module not wired yet — Step 3 wires it.) + +- [ ] **Step 3: Implement the config and wire the module** + +Prepend the implementation above the test module in `src/config/chronology.rs`: + +```rust +fn default_visible() -> bool { true } +fn default_percent() -> u8 { 32 } +fn default_min_cols() -> u16 { 24 } +fn default_max_cols() -> u16 { 60 } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Side { Left, Right } + +impl Default for Side { + fn default() -> Self { Side::Right } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WidthSpec { + #[serde(default = "default_percent")] + pub percent: u8, + #[serde(default = "default_min_cols")] + pub min_cols: u16, + #[serde(default = "default_max_cols")] + pub max_cols: u16, +} + +impl Default for WidthSpec { + fn default() -> Self { + Self { + percent: default_percent(), + min_cols: default_min_cols(), + max_cols: default_max_cols(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChronologyConfig { + #[serde(default = "default_visible")] + pub visible: bool, + #[serde(default)] + pub side: Side, + #[serde(default)] + pub width: WidthSpec, +} + +impl Default for ChronologyConfig { + fn default() -> Self { + Self { + visible: default_visible(), + side: Side::default(), + width: WidthSpec::default(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChronologyOverride { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visible: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, +} + +impl ChronologyConfig { + pub fn with_override(mut self, ovr: &ChronologyOverride) -> Self { + if let Some(v) = ovr.visible { + self.visible = v; + } + if let Some(s) = ovr.side { + self.side = s; + } + if let Some(w) = &ovr.width { + self.width = w.clone(); + } + self + } + + /// Clamp into legal ranges and swap inverted min/max. Idempotent. + pub fn sanitize(&mut self) { + self.width.percent = self.width.percent.clamp(10, 80); + self.width.min_cols = self.width.min_cols.clamp(12, 120); + self.width.max_cols = self.width.max_cols.clamp(12, 160); + if self.width.min_cols > self.width.max_cols { + std::mem::swap(&mut self.width.min_cols, &mut self.width.max_cols); + } + } + + /// Column width for an attach area `total` columns wide: `percent` of + /// `total`, clamped to `[min_cols, max_cols]`. + pub fn resolved_width(&self, total: u16) -> u16 { + let target = (u32::from(total) * u32::from(self.width.percent) / 100) as u16; + target.clamp(self.width.min_cols, self.width.max_cols) + } +} + +/// Resolve the global config only (no repo override). Defaults on missing key +/// or parse failure. Mirrors `detail_bar_config::resolve_global_only`. +pub fn resolve_global_only(store: &Store) -> ChronologyConfig { + let mut cfg = match store.get_setting("chronology_config") { + Ok(Some(raw)) => serde_json::from_str(&raw).unwrap_or_else(|e| { + tracing::warn!(error = %e, "chronology_config: global parse failed; using defaults"); + ChronologyConfig::default() + }), + _ => ChronologyConfig::default(), + }; + cfg.sanitize(); + cfg +} + +/// Resolve global config with the per-repo override applied. Mirrors +/// `detail_bar_config::resolve`. +pub fn resolve(repo: &Repo, store: &Store) -> ChronologyConfig { + let mut cfg = resolve_global_only(store); + if let Some(raw) = repo.chronology_config.as_deref() { + match serde_json::from_str::(raw) { + Ok(ovr) => cfg = cfg.with_override(&ovr), + Err(e) => { + tracing::warn!(error = %e, "chronology_config: repo override parse failed; ignoring"); + } + } + } + cfg.sanitize(); + cfg +} +``` + +In `src/config/mod.rs`, add next to `pub mod detail_bar_config;`: + +```rust +pub mod chronology; +``` + +> NOTE: `resolve`/`resolve_global_only` reference `repo.chronology_config`, added to the `Repo` struct in Task 10. Implement Task 10 before running the `resolve` paths; the `tests` module here does not touch the store, so it compiles and passes once Task 10's field exists. Sequence Task 10 immediately after this task (do not commit a non-compiling tree). + +- [ ] **Step 4: (after Task 10) Run to verify pass** + +Run: `cargo test --lib config::chronology` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit (jointly with Task 10)** + +Commit message in Task 10. + +### Task 10: `repos.chronology_config` column, migration, accessor + +**Files:** +- Modify: `src/data/store.rs` + +- [ ] **Step 1: Write the failing test** + +Add to the `tests` module in `src/data/store.rs` (mirror `detail_bar_config_column_round_trips`): + +```rust +#[test] +fn chronology_config_column_round_trips() { + let store = Store::open_in_memory().unwrap(); + let id = store.add_repo("r", std::path::Path::new("/tmp/r"), "wsx/").unwrap(); + let repo = store.repos().unwrap().into_iter().find(|r| r.id == id).unwrap(); + assert!(repo.chronology_config.is_none()); + store + .set_repo_chronology_config(id, Some(r#"{"visible":false}"#)) + .unwrap(); + let repo = store.repos().unwrap().into_iter().find(|r| r.id == id).unwrap(); + assert_eq!(repo.chronology_config.as_deref(), Some(r#"{"visible":false}"#)); +} +``` + +> If `add_repo`'s signature differs, copy the exact call used by the adjacent `detail_bar_config_column_round_trips` test (read it first). + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib chronology_config_column_round_trips` +Expected: FAIL — `no field chronology_config` / `no method set_repo_chronology_config`. + +- [ ] **Step 3: Implement the column** + +In `src/data/store.rs`: + +1. Add the field to `struct Repo` immediately after `detail_bar_config`: + +```rust + pub chronology_config: Option, +``` + +2. Add the migration alongside the others (after the `detail_bar_config` migration block near line 216): + +```rust + let has_chronology: i64 = self.conn.query_row( + "SELECT count(*) FROM pragma_table_info('repos') WHERE name = 'chronology_config'", + [], + |r| r.get(0), + )?; + if has_chronology == 0 { + self.conn + .execute("ALTER TABLE repos ADD COLUMN chronology_config TEXT", [])?; + } +``` + +3. In `repos()`, add `chronology_config` to the SELECT list **after** `detail_bar_config` and before `created_at`, then bump the `created_at` index: + +```rust + "SELECT id, name, path, branch_prefix, custom_instructions, \ + setup_script, archive_script, pinned_commands, \ + related_repos, base_branch, detail_bar_config, \ + chronology_config, created_at \ + FROM repos ORDER BY id", +``` + +and in the row mapping: + +```rust + detail_bar_config: r.get(10)?, + chronology_config: r.get(11)?, + created_at: r.get(12)?, +``` + +4. Add the setter next to `set_repo_detail_bar_config`: + +```rust + pub fn set_repo_chronology_config(&self, id: RepoId, value: Option<&str>) -> Result<()> { + self.conn.execute( + "UPDATE repos SET chronology_config = ?1 WHERE id = ?2", + rusqlite::params![value, id.0], + )?; + Ok(()) + } +``` + +> Check every other place that constructs a `Repo { .. }` literal (e.g. test helpers in `detail_bar_config.rs`/`chronology.rs` config tests) and add `chronology_config: None`. Search: `grep -rn "detail_bar_config:" src` and add the sibling field at each literal. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib chronology_config_column_round_trips && cargo test --lib config::chronology` +Expected: PASS (store round-trip + the 4 config tests from Task 9). + +- [ ] **Step 5: Commit** + +```bash +git add src/config/chronology.rs src/config/mod.rs src/data/store.rs +git commit -m "feat(chronology): config struct + repos.chronology_config column" +``` + +--- + +## Phase 5 — CLI config surface + +### Task 11: `chronology_config` config key + +**Files:** +- Modify: `src/cli.rs` + +- [ ] **Step 1: Write the failing tests** + +Add to the `tests` module in `src/cli.rs` (mirror `detail_bar_config_validate_and_normalize`): + +```rust +#[test] +fn chronology_config_validate_accepts_partial_json() { + let out = chronology_config_validate_and_normalize(r#"{"side":"left"}"#).unwrap(); + assert!(out.contains("\"side\"")); +} + +#[test] +fn chronology_config_validate_rejects_bad_json() { + assert!(chronology_config_validate_and_normalize("{not json").is_err()); +} + +#[test] +fn chronology_config_seed_is_valid_json() { + let seed = chronology_config_seed_for_empty(); + assert!(serde_json::from_str::(&seed).is_ok()); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib chronology_config_` +Expected: FAIL — functions not found. + +- [ ] **Step 3: Implement the key handling** + +In `src/cli.rs`: + +1. Add the validator/seed helpers next to `detail_bar_config_validate_and_normalize` (~line 1467): + +```rust +fn chronology_config_validate_and_normalize(raw: &str) -> Result { + let cfg: crate::config::chronology::ChronologyConfig = serde_json::from_str(raw) + .map_err(|e| Error::UserInput(format!("chronology_config: invalid JSON: {e}")))?; + serde_json::to_string(&cfg) + .map_err(|e| Error::UserInput(format!("chronology_config: serialize failed: {e}"))) +} + +fn chronology_config_seed_for_empty() -> String { + serde_json::to_string_pretty(&crate::config::chronology::ChronologyConfig::default()) + .unwrap_or_else(|_| "{}".to_string()) +} +``` + +2. Add `"chronology_config"` to the valid-keys match arm (the `| "detail_bar_config" | "usage_graph_window"` list near line 399): + +```rust + | "detail_bar_config" + | "chronology_config" + | "usage_graph_window" +``` + +3. In the `CliAction::ConfigSet` handler (~line 1171), extend the validate/normalize and seed branches the same way they handle `detail_bar_config`: + +```rust + let value = if key == "detail_bar_config" { + detail_bar_config_validate_and_normalize(&raw)? + } else if key == "chronology_config" { + chronology_config_validate_and_normalize(&raw)? + } else if key == "usage_graph_window" { + usage_graph_window_validate(&raw)? + } else { + raw + }; +``` + +and the seed branch (~line 1205): + +```rust + let seed = if key == "detail_bar_config" && current.is_empty() { + detail_bar_config_seed_for_empty() + } else if key == "chronology_config" && current.is_empty() { + chronology_config_seed_for_empty() + } else { + current + }; +``` + +and the normalized branch (~line 1218): + +```rust + let normalized = if key == "detail_bar_config" { + detail_bar_config_validate_and_normalize(&edited)? + } else if key == "chronology_config" { + chronology_config_validate_and_normalize(&edited)? + } else if key == "usage_graph_window" { + usage_graph_window_validate(&edited)? + } else { + edited + }; +``` + +> Read the exact surrounding code at each `~line` before editing — variable names (`raw`/`edited`/`current`) must match what's there. Adapt to the actual structure rather than assuming. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib chronology_config_ && cargo build` +Expected: PASS (3 tests) and clean build. + +- [ ] **Step 5: Commit** + +```bash +git add src/cli.rs +git commit -m "feat(cli): chronology_config config key (validate/normalize/seed)" +``` + +--- + +## Phase 6 — Rendering helpers and attached-view integration + +### Task 12: Pure render helpers (entry lines, relative path, auto-hide) + +**Files:** +- Create: `src/ui/chronology_bar.rs` +- Modify: `src/ui/mod.rs` + +- [ ] **Step 1: Write the failing tests** + +Create `src/ui/chronology_bar.rs` with tests first: + +```rust +//! Pure rendering helpers for the change-chronology bar. The host +//! (`src/ui/attached.rs`) carves the side column and calls these to build the +//! content lines; keeping the formatting pure makes it unit-testable. + +use crate::activity::chronology::{ChangeDetail, ChangeEvent, ChangeTool}; +use std::path::Path; + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn ev(file: &str, summary: &str) -> ChangeEvent { + ChangeEvent { + timestamp_ms: 0, + tool: ChangeTool::Edit, + file_path: PathBuf::from(file), + summary: summary.to_string(), + detail: ChangeDetail::Edit { old: "a".into(), new: "b".into() }, + } + } + + #[test] + fn relative_path_strips_worktree_prefix() { + let p = relative_display(Path::new("/wt/src/main.rs"), Path::new("/wt")); + assert_eq!(p, "src/main.rs"); + } + + #[test] + fn relative_path_passthrough_when_not_prefixed() { + let p = relative_display(Path::new("/other/x.rs"), Path::new("/wt")); + assert_eq!(p, "/other/x.rs"); + } + + #[test] + fn auto_hide_when_area_too_narrow() { + // bar wants 30 cols, agent needs >= MIN_AGENT_COLS; 35-wide area hides it. + assert!(should_auto_hide(35, 30)); + assert!(!should_auto_hide(120, 30)); + } + + #[test] + fn entry_produces_header_and_summary_lines() { + let lines = entry_lines(&ev("/wt/src/main.rs", "fn foo()"), Path::new("/wt"), false, 40); + assert_eq!(lines.len(), 2, "B fidelity: header + summary, no diff peek"); + } + + #[test] + fn expanded_entry_adds_diff_peek_lines() { + let lines = entry_lines(&ev("/wt/src/main.rs", "fn foo()"), Path::new("/wt"), true, 40); + assert!(lines.len() > 2, "expanded entry includes diff peek"); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib chronology_bar` +Expected: FAIL — functions/module not found. + +- [ ] **Step 3: Implement the helpers and wire the module** + +Prepend to `src/ui/chronology_bar.rs`: + +```rust +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; + +/// Minimum columns the agent pane must keep for the bar to be allowed. +pub const MIN_AGENT_COLS: u16 = 40; + +/// Worktree-relative display path, falling back to the full path when the file +/// is not under the worktree. +pub fn relative_display(file: &Path, worktree: &Path) -> String { + match file.strip_prefix(worktree) { + Ok(rel) => rel.to_string_lossy().to_string(), + Err(_) => file.to_string_lossy().to_string(), + } +} + +/// Hide the bar when carving `bar_cols` would leave the agent < MIN_AGENT_COLS. +pub fn should_auto_hide(area_cols: u16, bar_cols: u16) -> bool { + area_cols.saturating_sub(bar_cols) < MIN_AGENT_COLS +} + +fn hhmm(timestamp_ms: i64) -> String { + // Local wall-clock is not needed for a relative glance; show HH:MM in UTC + // derived from epoch ms without pulling in chrono (matches events.rs style). + let secs = timestamp_ms / 1000; + let h = (secs / 3600) % 24; + let m = (secs / 60) % 60; + format!("{h:02}:{m:02}") +} + +/// Render one entry into lines. Line 1: `HH:MM file`. Line 2: dim summary. +/// When `expanded`, appends up to a few diff-peek lines from `detail`. +pub fn entry_lines( + ev: &ChangeEvent, + worktree: &Path, + expanded: bool, + width: u16, +) -> Vec> { + let mut out = Vec::new(); + let rel = relative_display(&ev.file_path, worktree); + out.push(Line::from(vec![ + Span::styled(hhmm(ev.timestamp_ms), Style::default().add_modifier(Modifier::DIM)), + Span::raw(" "), + Span::raw(rel), + ])); + out.push(Line::from(Span::styled( + ev.summary.clone(), + Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC), + ))); + if expanded { + let peek: Vec = match &ev.detail { + ChangeDetail::Edit { old, new } => { + let mut v = Vec::new(); + for l in old.lines().take(2) { + v.push(format!("- {l}")); + } + for l in new.lines().take(2) { + v.push(format!("+ {l}")); + } + v + } + ChangeDetail::Write { head } => { + head.lines().take(3).map(|l| format!("+ {l}")).collect() + } + ChangeDetail::None => Vec::new(), + }; + for l in peek { + let clipped: String = l.chars().take(width as usize).collect(); + out.push(Line::from(Span::styled( + clipped, + Style::default().add_modifier(Modifier::DIM), + ))); + } + } + out +} +``` + +In `src/ui/mod.rs`, add next to the other `pub mod` lines: + +```rust +pub mod chronology_bar; +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib chronology_bar` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/chronology_bar.rs src/ui/mod.rs +git commit -m "feat(chronology): pure render helpers for the chronology bar" +``` + +### Task 13: App state for the timeline and bar UI + +**Files:** +- Modify: `src/app.rs` (App struct + construction) and the attached-view state + +- [ ] **Step 1: Add state fields** + +This task has no isolated unit test (it's plumbing verified by `cargo build` + later interaction tests). Add to the `App` struct in `src/app.rs`: + +```rust + /// Per-workspace change-chronology timelines, keyed by workspace id. + /// Lazily built/refreshed while attached. + pub chronology: std::collections::HashMap, + /// Scroll offset (entries from the top) of the chronology bar in the + /// focused attached pane. + pub chronology_scroll: usize, + /// Index of the currently expanded chronology entry, if any. + pub chronology_expanded: Option, +``` + +> Use the actual `WorkspaceId` type as declared in `src/data/store.rs` (confirm the name; the codebase uses `RepoId(pub i64)` and an `AgentInstanceId` — find the workspace-id type and match it). Initialize the three fields in every `App` constructor: `chronology: HashMap::new(), chronology_scroll: 0, chronology_expanded: None`. + +- [ ] **Step 2: Add a refresh helper** + +Add an `impl App` method in `src/app.rs`: + +```rust + /// Refresh the chronology timeline for `worktree`/`workspace_id` from the + /// on-disk session logs. Cheap when nothing changed (per-file cache). + pub fn refresh_chronology( + &mut self, + workspace_id: crate::data::store::WorkspaceId, + worktree: &std::path::Path, + ) { + let files = crate::activity::chronology::claude_session_files(worktree); + self.chronology + .entry(workspace_id) + .or_default() + .refresh(&files); + } +``` + +- [ ] **Step 3: Verify build** + +Run: `cargo build` +Expected: clean build. + +- [ ] **Step 4: Commit** + +```bash +git add src/app.rs +git commit -m "feat(chronology): app state + refresh helper for timelines" +``` + +### Task 14: Carve the side column and render the bar in the attached view + +**Files:** +- Modify: `src/ui/attached.rs`, `src/app/render.rs` + +This integrates the bar into `render_panes`. The exact wiring depends on how `render_panes` is called from `src/app/render.rs`; read both before editing. + +- [ ] **Step 1: Extend `PanesDrawOutput` and `render_panes`** + +In `src/ui/attached.rs`, add a field to `PanesDrawOutput`: + +```rust + /// `(entry_index, clickable_rect)` for each rendered chronology entry in the + /// focused pane's bar. Empty when the bar isn't shown. Consumed by the input + /// handler for click → expand / open-in-editor. + pub chronology_entry_rects: Vec<(usize, Rect)>, +``` + +Add parameters to `render_panes` for the resolved config, the focused timeline events, the scroll offset, the expanded index, and the focused worktree path: + +```rust + chronology: Option>, +``` + +with a small struct near `PaneSpec`: + +```rust +/// Everything `render_panes` needs to draw the chronology bar for the focused +/// pane. `None` (passed at the call site) means the bar is disabled/hidden. +pub struct ChronologyDraw<'a> { + pub config: &'a crate::config::chronology::ChronologyConfig, + pub events: &'a [crate::activity::chronology::ChangeEvent], + pub worktree: &'a std::path::Path, + pub scroll: usize, + pub expanded: Option, +} +``` + +- [ ] **Step 2: Carve the column before laying out panes** + +At the top of `render_panes`, before computing `pane_rects`, compute the bar rect and shrink the pane area. The function currently treats the incoming pane rects as the full area; introduce a helper that, given the overall pane area and the `ChronologyDraw`, returns `(agent_area, Option)`: + +```rust +use crate::config::chronology::Side; +use crate::ui::chronology_bar::{entry_lines, should_auto_hide, MIN_AGENT_COLS}; + +/// Split `area` into (agent_area, Some(bar_rect)) per the chronology config, +/// or (area, None) when disabled/auto-hidden. +fn split_for_chronology(area: Rect, draw: &Option>) -> (Rect, Option) { + let Some(draw) = draw else { return (area, None) }; + if !draw.config.visible { + return (area, None); + } + let bar_cols = draw.config.resolved_width(area.width); + if should_auto_hide(area.width, bar_cols) { + return (area, None); + } + match draw.config.side { + Side::Right => { + let agent = Rect { width: area.width - bar_cols, ..area }; + let bar = Rect { x: area.x + area.width - bar_cols, width: bar_cols, ..area }; + (agent, Some(bar)) + } + Side::Left => { + let bar = Rect { width: bar_cols, ..area }; + let agent = Rect { x: area.x + bar_cols, width: area.width - bar_cols, ..area }; + (agent, Some(bar)) + } + } +} +``` + +> `render_panes` receives pre-computed per-pane rects from the caller (`SplitTree::layout`). The cleanest integration is to move the chronology split **into the caller** (`src/app/render.rs`): compute `(agent_area, bar_rect)` from the full attach content area, run the existing split layout against `agent_area`, then pass `bar_rect` + `ChronologyDraw` into `render_panes` for it to paint. Implement the split in `render.rs` and pass the resulting `bar_rect: Option` to `render_panes`. Adjust the signatures accordingly; keep `split_for_chronology` as the shared pure helper (unit-test it as in Step 4). + +- [ ] **Step 3: Paint the bar and collect click rects** + +In `render_panes`, after panes are drawn, if a `bar_rect` and `ChronologyDraw` are present, paint: +- a 1-column divider on the inner edge reusing the `render_dividers` style; +- the `CHANGE CHRONOLOGY` header line with a side indicator; +- entries from `draw.scroll` onward via `entry_lines(ev, draw.worktree, Some(i)==draw.expanded, inner_width)`, tracking the `Rect` each entry's header line occupies and pushing `(i, rect)` into `chronology_entry_rects`; +- an em-dash placeholder line when `draw.events` is empty. + +Use a `Paragraph` per the existing detail-bar rendering style in `src/ui/dashboard/detail.rs` for reference. + +- [ ] **Step 4: Add a unit test for the split helper** + +Add to `src/ui/attached.rs` tests (or a small `#[cfg(test)]` block): + +```rust +#[test] +fn split_right_carves_bar_on_right() { + let cfg = crate::config::chronology::ChronologyConfig::default(); + let events: Vec = Vec::new(); + let draw = ChronologyDraw { + config: &cfg, + events: &events, + worktree: std::path::Path::new("/wt"), + scroll: 0, + expanded: None, + }; + let area = Rect { x: 0, y: 0, width: 200, height: 50 }; + let (agent, bar) = split_for_chronology(area, &Some(draw)); + let bar = bar.expect("bar shown at 200 cols"); + assert_eq!(agent.width + bar.width, 200); + assert!(bar.x > agent.x, "right side"); +} + +#[test] +fn split_hidden_when_too_narrow() { + let cfg = crate::config::chronology::ChronologyConfig::default(); + let events: Vec = Vec::new(); + let draw = ChronologyDraw { + config: &cfg, events: &events, worktree: std::path::Path::new("/wt"), + scroll: 0, expanded: None, + }; + let area = Rect { x: 0, y: 0, width: 50, height: 50 }; + let (_agent, bar) = split_for_chronology(area, &Some(draw)); + assert!(bar.is_none(), "auto-hidden when agent would be < MIN_AGENT_COLS"); +} +``` + +- [ ] **Step 5: Wire the call site in `render.rs`** + +In `src/app/render.rs`, where the attached view is rendered: +- resolve the focused pane's repo + workspace, call `app.refresh_chronology(workspace_id, worktree)`; +- `let cfg = crate::config::chronology::resolve(repo, &app.store);` +- build `ChronologyDraw` from `cfg`, `app.chronology[&workspace_id].events()`, the worktree, `app.chronology_scroll`, `app.chronology_expanded`; +- perform the chronology split on the attach content area, lay out panes against the agent area, pass `bar_rect` + `ChronologyDraw` into `render_panes`. + +> Store the returned `chronology_entry_rects` on `App` (e.g. a transient `app.chronology_entry_rects: Vec<(usize, Rect)>` field, set each draw) for the input handler in Task 15. Add and initialize that field as in Task 13. + +- [ ] **Step 6: Run tests + build** + +Run: `cargo test --lib attached && cargo build` +Expected: PASS (2 split tests) and clean build. Manually verify the bar appears when attached. + +- [ ] **Step 7: Commit** + +```bash +git add src/ui/attached.rs src/app/render.rs src/app.rs +git commit -m "feat(chronology): carve side column and render the bar in attached view" +``` + +### Task 15: Keybindings, scroll, click → expand / open editor + +**Files:** +- Modify: `src/app/input.rs` + +- [ ] **Step 1: Add the leader follow-ups** + +In `src/app/input.rs`, inside the `if app.leader_pending { match k.code { … } }` block (the attached-mode leader, ~line 707, alongside `Char('e')`), add: + +```rust + KeyCode::Char('c') => { + // Toggle the chronology bar (persist to the global setting so + // the change survives detach and matches the CLI). + toggle_chronology_visible(app); + return Ok(()); + } + KeyCode::Char('C') => { + // Swap the chronology bar's side (left <-> right), persisted. + swap_chronology_side(app); + return Ok(()); + } +``` + +- [ ] **Step 2: Implement the toggle/swap helpers** + +Add to `src/app/input.rs`: + +```rust +fn toggle_chronology_visible(app: &mut App) { + let mut cfg = crate::config::chronology::resolve_global_only(&app.store); + cfg.visible = !cfg.visible; + if let Ok(json) = serde_json::to_string(&cfg) { + let _ = app.store.set_setting("chronology_config", &json); + } +} + +fn swap_chronology_side(app: &mut App) { + use crate::config::chronology::Side; + let mut cfg = crate::config::chronology::resolve_global_only(&app.store); + cfg.side = match cfg.side { + Side::Left => Side::Right, + Side::Right => Side::Left, + }; + if let Ok(json) = serde_json::to_string(&cfg) { + let _ = app.store.set_setting("chronology_config", &json); + } +} +``` + +> `set_setting` exists on `Store` (`src/data/store.rs:442`). These write the **global** config; per-repo overrides are managed via the CLI per the spec. + +- [ ] **Step 3: Handle scroll and clicks** + +In the attached-view mouse handling (find where `pane_rects`/`chip_rects` from `PanesDrawOutput` are hit-tested): +- On scroll-up/down with the cursor over the bar rect, adjust `app.chronology_scroll` (saturating; clamp to event count). +- On click within a `chronology_entry_rects` entry rect: if it's already `app.chronology_expanded`, open the editor; otherwise set `app.chronology_expanded = Some(index)`. + +Opening the editor on a second click (or a dedicated modifier — keep it simple: click toggles expand, and a click on the already-expanded entry opens it): + +```rust +// pseudocode at the hit-test site — adapt to the surrounding handler: +if let Some((idx, _)) = app.chronology_entry_rects.iter().find(|(_, r)| rect_contains(*r, col, row)) { + let idx = *idx; + if app.chronology_expanded == Some(idx) { + // open editor at file:line + if let Some(ev) = focused_timeline_events.get(idx) { + let line = crate::activity::chronology::resolve_line_in_file(&ev.file_path, &ev.detail); + let editor = app.store.get_setting("editor_cmd").ok().flatten(); + let _ = crate::commands::external::open_in_editor_at( + worktree, &ev.file_path, line, editor.as_deref(), + ); + } + } else { + app.chronology_expanded = Some(idx); + } + return Ok(()); +} +``` + +> Match the exact mouse-event plumbing already used for `chip_rects` (column/row extraction, the `rect_contains`-equivalent helper). Reuse existing helpers rather than adding new ones where they exist. + +- [ ] **Step 4: Build and smoke-test** + +Run: `cargo build` +Expected: clean build. Manually: attach, press `Ctrl-x c` to toggle, `Ctrl-x C` to swap side, scroll the bar, click an entry to expand, click again to open the editor at the line. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/input.rs +git commit -m "feat(chronology): Ctrl-x c/C toggle+swap, scroll, click to expand/open editor" +``` + +--- + +## Phase 7 — Documentation + +### Task 16: README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Document the feature** + +Add a "Change chronology" subsection under "Configuration and customization" (near the detail-bar and usage-graph docs). Cover: +- what it is (newest-first, time-ordered series of agent file changes in the attached view); +- keybindings: `Ctrl-x c` (toggle), `Ctrl-x C` (swap side); +- click an entry to expand its diff peek; click again to open your editor at the changed line (requires `editor_cmd` supporting `{file}`/`{line}`, or a recognized editor — `code`, `vim`/`nvim`, `emacs`); +- config: `wsx config set chronology_config ''` (global) and the per-repo override, with the field list (`visible`, `side`, `width.percent`, `width.min_cols`, `width.max_cols`); +- note that history is reconstructed from the agent's session logs (currently Claude; other agents land incrementally). + +Add the two keybindings to the attached-view keybindings table as well. + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: document the change chronology view" +``` + +--- + +## Phase 8 — Other agents (incremental, post-Claude) + +> Each task reuses the shared `ChangeEvent`/`ChangeTool`/`ChangeDetail` types and the `Timeline`. The work is per-agent extraction: read the agent's existing tool-use parser (each already extracts `edited_file_paths`), and add `extract_change_events_` plus an agent-aware variant of `claude_session_files` for that agent's log directory layout. + +### Task 17: Codex extraction + +**Files:** +- Modify: `src/activity/chronology.rs`, reading `src/activity/codex_events.rs` + +- [ ] **Step 1:** Read `src/activity/codex_events.rs` and locate where it extracts edited file paths and tool calls (e.g. `apply_patch`). Note the on-disk session-log location for Codex. +- [ ] **Step 2:** Write a failing test `extract_codex_*` with a representative Codex JSONL/log line (copy a real shape from the existing codex tests). +- [ ] **Step 3:** Implement `extract_change_events_codex(&serde_json::Value) -> Vec` and a `codex_session_files(worktree)` enumerator. When Codex doesn't expose old/new text, set `ChangeDetail::None` (B-only; no expand, line resolution returns 1). +- [ ] **Step 4:** Extend `App::refresh_chronology` to merge events from all agents present for the workspace (call each agent's enumerator + parser; merge into one `Timeline`). The `Timeline::refresh` already accepts a flat `&[PathBuf]`, so pass the union of all agents' session files **only if** they share the JSONL shape; otherwise add a `Timeline::refresh_with(parser, files)` variant that takes a per-file parse fn. Prefer the latter to keep formats isolated. +- [ ] **Step 5:** `cargo test` + commit `feat(chronology): Codex change extraction`. + +### Task 18: Pi extraction + +Same shape as Task 17 against `src/activity/pi_events.rs`. Commit `feat(chronology): Pi change extraction`. + +### Task 19: Hermes extraction + +Same shape as Task 17 against `src/activity/hermes_events.rs`. Commit `feat(chronology): Hermes change extraction`. + +> When Phase 8 introduces a per-agent parse function, refactor `Timeline` to store events per `(agent, file)` and merge, so a single workspace running multiple agents shows a unified chronology. Add a test that merges a Claude file and a Codex file and asserts global newest-first ordering. + +--- + +## Self-Review (completed during planning) + +**Spec coverage:** +- Toggleable bar, left/right, global+per-repo → Tasks 9–11, 15 (config, store, CLI, keybindings). ✓ +- B default / C on expand → Task 12 (`entry_lines` expanded flag), Task 15 (expand on click). ✓ +- Click → editor at file:line → Tasks 1, 5, 15. ✓ +- Wider default width, configurable min → Task 9 (`percent: 32`, `min_cols`). ✓ +- Whole workspace history from logs → Tasks 6–8 (enumerate all files, merge, cache). ✓ +- All agents → Claude in Phase 2; Codex/Pi/Hermes in Phase 8. ✓ (sequenced, not dropped) +- One entry per edit, newest first → Task 4 (MultiEdit → N events), Task 8 (sort desc). ✓ +- Error handling (missing logs, malformed lines, narrow terminal, deleted files) → Tasks 6/7 (empty/skip), Task 12 (`should_auto_hide`), Task 5 (`resolve_line_in_file` returns 1 on read error). ✓ +- Testing per component → every task is TDD. ✓ + +**Placeholder scan:** No "TBD"/"add error handling"-style steps; the `~line` references include an explicit instruction to read surrounding code and adapt, with concrete code shown. Phase 8 tasks are intentionally recipe-style because the per-agent wire formats must be read at implementation time — they are bounded and reuse already-defined types, not placeholders. + +**Type consistency:** `ChangeEvent`/`ChangeTool`/`ChangeDetail`, `Timeline::refresh`/`events`/`parse_count`, `ChronologyConfig`/`ChronologyOverride`/`WidthSpec`/`Side`, `resolve_global_only`/`resolve`, `resolved_width`, `extract_change_events`, `resolve_line`/`resolve_line_in_file`, `session_files_in`/`claude_session_files`, `entry_lines`/`should_auto_hide`/`relative_display`/`MIN_AGENT_COLS`, `open_in_editor_at`/`resolve_editor_at_argv`, `set_repo_chronology_config` — names are used consistently across tasks. From c4460250f9193da131d18f063471b94ddb50ecf0 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 14:48:36 -0400 Subject: [PATCH 03/69] feat(editor): open_in_editor_at with {file}/{line} placeholders and goto fallbacks --- src/commands/external.rs | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/commands/external.rs b/src/commands/external.rs index 51686c8..aeeacca 100644 --- a/src/commands/external.rs +++ b/src/commands/external.rs @@ -198,6 +198,69 @@ fn resolve_argv( Ok(parts) } +/// Resolve the editor command into argv that opens `file` at `line`. +/// +/// If the template contains `{file}`/`{line}` placeholders, substitute them. +/// Otherwise fall back to the goto convention of common editors detected from +/// the program name: VS Code (`code --goto file:line`), vim/nvim/vi +/// (`+line file`), emacs/emacsclient (`+line file`); any other editor gets the +/// file appended with the line omitted. +fn resolve_editor_at_argv(cmd: &str, file: &str, line: u32) -> Result> { + let line_s = line.to_string(); + let mut parts = shlex::split(cmd) + .ok_or_else(|| Error::UserInput(format!("could not parse command: {cmd}")))?; + if parts.is_empty() { + return Err(Error::UserInput("command is empty".into())); + } + let used_placeholder = parts + .iter() + .any(|p| p.contains("{file}") || p.contains("{line}")); + if used_placeholder { + for part in &mut parts { + *part = part.replace("{file}", file).replace("{line}", &line_s); + } + return Ok(parts); + } + let prog = std::path::Path::new(&parts[0]) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(&parts[0]) + .to_string(); + match prog.as_str() { + "code" | "codium" | "cursor" => { + parts.push("--goto".to_string()); + parts.push(format!("{file}:{line_s}")); + } + "vim" | "nvim" | "vi" | "emacs" | "emacsclient" => { + parts.push(format!("+{line_s}")); + parts.push(file.to_string()); + } + _ => parts.push(file.to_string()), + } + Ok(parts) +} + +/// Resolve and launch the user's editor on `file`, positioned at `line`. +/// Spawns with cwd = `worktree`. Used by the chronology bar's entry clicks. +pub fn open_in_editor_at( + worktree: &Path, + file: &Path, + line: u32, + configured: Option<&str>, +) -> Result<()> { + let cmd = resolve_editor_cmd(configured)?; + let file_str = file.to_string_lossy(); + let mut parts = resolve_editor_at_argv(&cmd, file_str.as_ref(), line)?; + let program = parts.remove(0); + let mut command = std::process::Command::new(&program); + command.args(&parts).current_dir(worktree); + detach_io(&mut command); + command + .spawn() + .map_err(|e| Error::UserInput(format!("spawn {program}: {e}")))?; + Ok(()) +} + fn detach_io(cmd: &mut std::process::Command) { cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) @@ -393,4 +456,39 @@ mod tests { .unwrap(); assert_eq!(argv, vec!["tool", "--base={base}", "/tmp/wt"]); } + + #[test] + fn editor_at_substitutes_file_and_line_placeholders() { + let argv = resolve_editor_at_argv( + "code --goto {file}:{line}", + "/tmp/wt/src/main.rs", + 42, + ) + .unwrap(); + assert_eq!(argv, vec!["code", "--goto", "/tmp/wt/src/main.rs:42"]); + } + + #[test] + fn editor_at_vim_fallback_uses_plus_line() { + let argv = resolve_editor_at_argv("nvim", "/tmp/wt/src/main.rs", 42).unwrap(); + assert_eq!(argv, vec!["nvim", "+42", "/tmp/wt/src/main.rs"]); + } + + #[test] + fn editor_at_code_fallback_uses_goto() { + let argv = resolve_editor_at_argv("code", "/tmp/wt/src/main.rs", 7).unwrap(); + assert_eq!(argv, vec!["code", "--goto", "/tmp/wt/src/main.rs:7"]); + } + + #[test] + fn editor_at_emacs_fallback_uses_plus_line() { + let argv = resolve_editor_at_argv("emacsclient", "/tmp/wt/a.rs", 3).unwrap(); + assert_eq!(argv, vec!["emacsclient", "+3", "/tmp/wt/a.rs"]); + } + + #[test] + fn editor_at_unknown_editor_appends_file_only() { + let argv = resolve_editor_at_argv("myeditor", "/tmp/wt/a.rs", 3).unwrap(); + assert_eq!(argv, vec!["myeditor", "/tmp/wt/a.rs"]); + } } From 7ad7e071ddc87e059f14a3a16266b4333b57ae99 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 14:51:59 -0400 Subject: [PATCH 04/69] refactor(editor): share spawn_parts helper; test split placeholders Extract spawn_parts from spawn_resolved's tail so the detach-spawn sequence lives in one place. Route both spawn_resolved and open_in_editor_at through it, eliminating the duplicate implementation introduced in c446025. Add a test proving {file}/{line} substitute when they appear in separate argv tokens. --- src/commands/external.rs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/commands/external.rs b/src/commands/external.rs index aeeacca..283031e 100644 --- a/src/commands/external.rs +++ b/src/commands/external.rs @@ -155,7 +155,15 @@ fn spawn_resolved( substitutions: &[(&str, &str)], fallback_when_no_placeholder: Option<&str>, ) -> Result<()> { - let mut parts = resolve_argv(cmd, substitutions, fallback_when_no_placeholder)?; + let parts = resolve_argv(cmd, substitutions, fallback_when_no_placeholder)?; + spawn_parts(parts, cwd) +} + +/// Spawn `parts` (program + argv) detached, with cwd = `cwd`. +fn spawn_parts(mut parts: Vec, cwd: &Path) -> Result<()> { + if parts.is_empty() { + return Err(Error::UserInput("command is empty".into())); + } let program = parts.remove(0); let mut command = std::process::Command::new(&program); command.args(&parts).current_dir(cwd); @@ -250,15 +258,8 @@ pub fn open_in_editor_at( ) -> Result<()> { let cmd = resolve_editor_cmd(configured)?; let file_str = file.to_string_lossy(); - let mut parts = resolve_editor_at_argv(&cmd, file_str.as_ref(), line)?; - let program = parts.remove(0); - let mut command = std::process::Command::new(&program); - command.args(&parts).current_dir(worktree); - detach_io(&mut command); - command - .spawn() - .map_err(|e| Error::UserInput(format!("spawn {program}: {e}")))?; - Ok(()) + let parts = resolve_editor_at_argv(&cmd, file_str.as_ref(), line)?; + spawn_parts(parts, worktree) } fn detach_io(cmd: &mut std::process::Command) { @@ -491,4 +492,10 @@ mod tests { let argv = resolve_editor_at_argv("myeditor", "/tmp/wt/a.rs", 3).unwrap(); assert_eq!(argv, vec!["myeditor", "/tmp/wt/a.rs"]); } + + #[test] + fn editor_at_substitutes_placeholders_in_separate_tokens() { + let argv = resolve_editor_at_argv("nvim +{line} {file}", "/tmp/wt/a.rs", 9).unwrap(); + assert_eq!(argv, vec!["nvim", "+9", "/tmp/wt/a.rs"]); + } } From dabe5357191e302e23cd27eda70f65d8e63b63ea Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 14:53:10 -0400 Subject: [PATCH 05/69] feat(chronology): add ChangeEvent/ChangeTool/ChangeDetail types --- src/activity/chronology.rs | 56 ++++++++++++++++++++++++++++++++++++++ src/activity/mod.rs | 1 + 2 files changed, 57 insertions(+) create mode 100644 src/activity/chronology.rs diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs new file mode 100644 index 0000000..f8ddb74 --- /dev/null +++ b/src/activity/chronology.rs @@ -0,0 +1,56 @@ +//! Change Chronology: a newest-first, time-ordered series of individual file +//! changes the agent made, rebuilt from the on-disk session JSONL logs. +//! +//! The agent session logs are the source of truth (see +//! `docs/superpowers/specs/2026-06-05-change-chronology-view-design.md`). +//! This module scans ALL of a workspace's session files (not just the +//! live-tailed active one), extracts one `ChangeEvent` per mutating tool call, +//! and merges them into a timeline cached by each file's `(size, mtime)`. + +use crate::activity::events::{encode_cwd, parse_iso8601_ms}; +use std::path::{Path, PathBuf}; + +/// The mutating tool that produced a change. Read and non-mutating tools are +/// never recorded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChangeTool { + Edit, + MultiEdit, + Write, + NotebookEdit, +} + +impl ChangeTool { + /// Compact label for display (`edit` / `write` / …). + pub fn label(self) -> &'static str { + match self { + ChangeTool::Edit => "edit", + ChangeTool::MultiEdit => "edit", + ChangeTool::Write => "write", + ChangeTool::NotebookEdit => "edit", + } + } +} + +/// Bounded change text retained for the expandable diff peek (C fidelity). +/// `None` when the agent did not expose the changed text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChangeDetail { + Edit { old: String, new: String }, + Write { head: String }, + None, +} + +/// One change the agent made at one moment in time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangeEvent { + /// Epoch milliseconds, parsed from the JSONL line's `timestamp`. + pub timestamp_ms: i64, + pub tool: ChangeTool, + /// Absolute path as the agent reported it (display layer makes it relative). + pub file_path: PathBuf, + /// One-line "what" summary (B fidelity). + pub summary: String, + /// Change text for the C-expand peek. + pub detail: ChangeDetail, +} diff --git a/src/activity/mod.rs b/src/activity/mod.rs index 1292035..c929e02 100644 --- a/src/activity/mod.rs +++ b/src/activity/mod.rs @@ -5,6 +5,7 @@ //! on top of it. `proc` //! detects per-workspace processes via `lsof` (wsx observes, never spawns). +pub mod chronology; pub mod codex_events; pub mod events; pub mod hermes_events; From 1fa9450fb7a2e84fcfa740931cb455797030fa32 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 14:54:51 -0400 Subject: [PATCH 06/69] feat(chronology): summary heuristic for Edit/Write changes --- src/activity/chronology.rs | 87 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index f8ddb74..785df98 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -54,3 +54,90 @@ pub struct ChangeEvent { /// Change text for the C-expand peek. pub detail: ChangeDetail, } + +pub(crate) const SUMMARY_MAX_CHARS: usize = 80; + +/// True if a line looks like a declaration worth surfacing. +fn looks_like_decl(line: &str) -> bool { + let t = line.trim_start(); + const KW: [&str; 11] = [ + "fn ", "pub ", "def ", "class ", "struct ", "impl ", "enum ", "trait ", + "func ", "type ", "const ", + ]; + KW.iter().any(|k| t.starts_with(k)) +} + +fn truncate_summary(s: &str) -> String { + let trimmed = s.trim(); + if trimmed.chars().count() <= SUMMARY_MAX_CHARS { + return trimmed.to_string(); + } + let mut out: String = trimmed.chars().take(SUMMARY_MAX_CHARS - 1).collect(); + out.push('…'); + out +} + +/// Summarize an Edit/MultiEdit: prefer a declaration among lines present in +/// `new` but not `old`; else the first non-blank line of `new` not in `old`; +/// else the first non-blank line of `new`. +pub(crate) fn summarize_edit(old: &str, new: &str) -> String { + let old_lines: std::collections::HashSet<&str> = old.lines().collect(); + let changed: Vec<&str> = new + .lines() + .filter(|l| !old_lines.contains(*l) && !l.trim().is_empty()) + .collect(); + if let Some(decl) = changed.iter().find(|l| looks_like_decl(l)) { + return truncate_summary(decl); + } + if let Some(first) = changed.first() { + return truncate_summary(first); + } + match new.lines().find(|l| !l.trim().is_empty()) { + Some(l) => truncate_summary(l), + None => "edit".to_string(), + } +} + +/// Summarize a Write: the first declaration in the content, else "new file". +pub(crate) fn summarize_write(content: &str) -> String { + match content.lines().find(|l| looks_like_decl(l)) { + Some(decl) => truncate_summary(decl), + None => "new file".to_string(), + } +} + +#[cfg(test)] +mod summary_tests { + use super::*; + + #[test] + fn prefers_declaration_line() { + let s = summarize_edit("let x = 1;\n", "let x = 1;\npub fn foo() {}\n"); + assert_eq!(s, "pub fn foo() {}"); + } + + #[test] + fn falls_back_to_first_nonblank_changed_line() { + let s = summarize_edit("a = 1\n", "a = 2\n"); + assert_eq!(s, "a = 2"); + } + + #[test] + fn write_new_file_when_no_decl() { + let s = summarize_write("plain text\nmore text\n"); + assert_eq!(s, "new file"); + } + + #[test] + fn write_uses_first_declaration_when_present() { + let s = summarize_write("# header\nclass Thing:\n pass\n"); + assert_eq!(s, "class Thing:"); + } + + #[test] + fn truncates_long_summaries() { + let long = "x".repeat(200); + let s = summarize_edit("", &format!("{long}\n")); + assert!(s.chars().count() <= SUMMARY_MAX_CHARS); + } +} From 9a76548a0494ba00ed910454e981e417fbf485a8 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 14:57:54 -0400 Subject: [PATCH 07/69] feat(chronology): extract ChangeEvents from Claude JSONL lines --- src/activity/chronology.rs | 155 +++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index 785df98..e9fb146 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -98,6 +98,109 @@ pub(crate) fn summarize_edit(old: &str, new: &str) -> String { } } +/// Bounded number of characters retained per side of a diff peek. +const DETAIL_MAX_CHARS: usize = 600; + +fn clip(s: &str) -> String { + s.chars().take(DETAIL_MAX_CHARS).collect() +} + +fn tool_from_name(name: &str) -> Option { + match name { + "Edit" => Some(ChangeTool::Edit), + "MultiEdit" => Some(ChangeTool::MultiEdit), + "Write" => Some(ChangeTool::Write), + "NotebookEdit" => Some(ChangeTool::NotebookEdit), + _ => None, + } +} + +/// Extract zero or more `ChangeEvent`s from one parsed Claude JSONL line. +/// Only `type == "assistant"` lines with mutating `tool_use` blocks produce +/// events. A `MultiEdit` produces one event per element of its `edits` array. +pub fn extract_change_events(v: &serde_json::Value) -> Vec { + let mut out = Vec::new(); + if v.get("type").and_then(|t| t.as_str()) != Some("assistant") { + return out; + } + let ts = v + .get("timestamp") + .and_then(|t| t.as_str()) + .and_then(parse_iso8601_ms) + .unwrap_or(0); + let Some(blocks) = v + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + else { + return out; + }; + for block in blocks { + if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") { + continue; + } + let name = block.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let Some(tool) = tool_from_name(name) else { + continue; + }; + let input = block.get("input").unwrap_or(&serde_json::Value::Null); + let file = input + .get("file_path") + .or_else(|| input.get("notebook_path")) + .and_then(|p| p.as_str()); + let Some(file) = file else { continue }; + let file_path = PathBuf::from(file); + + match tool { + ChangeTool::Write => { + let content = input.get("content").and_then(|c| c.as_str()).unwrap_or(""); + out.push(ChangeEvent { + timestamp_ms: ts, + tool, + file_path, + summary: summarize_write(content), + detail: ChangeDetail::Write { head: clip(content) }, + }); + } + ChangeTool::MultiEdit => { + let edits = input.get("edits").and_then(|e| e.as_array()); + if let Some(edits) = edits { + for e in edits { + let old = e.get("old_string").and_then(|s| s.as_str()).unwrap_or(""); + let new = e.get("new_string").and_then(|s| s.as_str()).unwrap_or(""); + out.push(ChangeEvent { + timestamp_ms: ts, + tool, + file_path: file_path.clone(), + summary: summarize_edit(old, new), + detail: ChangeDetail::Edit { old: clip(old), new: clip(new) }, + }); + } + } + } + ChangeTool::Edit | ChangeTool::NotebookEdit => { + let old = input + .get("old_string") + .and_then(|s| s.as_str()) + .unwrap_or(""); + let new = input + .get("new_string") + .or_else(|| input.get("new_source")) + .and_then(|s| s.as_str()) + .unwrap_or(""); + out.push(ChangeEvent { + timestamp_ms: ts, + tool, + file_path, + summary: summarize_edit(old, new), + detail: ChangeDetail::Edit { old: clip(old), new: clip(new) }, + }); + } + } + } + out +} + /// Summarize a Write: the first declaration in the content, else "new file". pub(crate) fn summarize_write(content: &str) -> String { match content.lines().find(|l| looks_like_decl(l)) { @@ -106,6 +209,58 @@ pub(crate) fn summarize_write(content: &str) -> String { } } +#[cfg(test)] +mod extract_tests { + use super::*; + + fn line(json: &str) -> serde_json::Value { + serde_json::from_str(json).unwrap() + } + + #[test] + fn extracts_edit_event() { + let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"/wt/a.rs","old_string":"let x=1;","new_string":"pub fn foo() {}"}}]}}"#); + let evs = extract_change_events(&v); + assert_eq!(evs.len(), 1); + assert_eq!(evs[0].tool, ChangeTool::Edit); + assert_eq!(evs[0].file_path, std::path::PathBuf::from("/wt/a.rs")); + assert_eq!(evs[0].summary, "pub fn foo() {}"); + assert!(matches!(evs[0].detail, ChangeDetail::Edit { .. })); + assert_eq!(evs[0].timestamp_ms, parse_iso8601_ms("2026-05-14T17:32:02.744Z").unwrap()); + } + + #[test] + fn extracts_write_event() { + let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t2","name":"Write","input":{"file_path":"/wt/new.rs","content":"pub struct Z;"}}]}}"#); + let evs = extract_change_events(&v); + assert_eq!(evs.len(), 1); + assert_eq!(evs[0].tool, ChangeTool::Write); + assert_eq!(evs[0].summary, "pub struct Z;"); + assert!(matches!(&evs[0].detail, ChangeDetail::Write { head } if head.contains("struct Z"))); + } + + #[test] + fn multiedit_emits_one_event_per_edit() { + let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t3","name":"MultiEdit","input":{"file_path":"/wt/a.rs","edits":[{"old_string":"a","new_string":"pub fn one(){}"},{"old_string":"b","new_string":"pub fn two(){}"}]}}]}}"#); + let evs = extract_change_events(&v); + assert_eq!(evs.len(), 2); + assert_eq!(evs[0].tool, ChangeTool::MultiEdit); + assert_eq!(evs[1].summary, "pub fn two(){}"); + } + + #[test] + fn ignores_read_and_bash() { + let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t4","name":"Read","input":{"file_path":"/wt/a.rs"}},{"type":"tool_use","id":"t5","name":"Bash","input":{"command":"ls"}}]}}"#); + assert!(extract_change_events(&v).is_empty()); + } + + #[test] + fn ignores_non_assistant_lines() { + let v = line(r#"{"type":"user","timestamp":"2026-05-14T17:32:02.744Z","message":{"role":"user","content":"hi"}}"#); + assert!(extract_change_events(&v).is_empty()); + } +} + #[cfg(test)] mod summary_tests { use super::*; From ab12e62a266787c5f8be5bd251dd6a047ad59387 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:01:05 -0400 Subject: [PATCH 08/69] feat(chronology): resolve editor line from old_string in current file --- src/activity/chronology.rs | 55 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index e9fb146..b564c0e 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -261,6 +261,61 @@ mod extract_tests { } } +/// 1-based line to open the editor at, given the file's current contents and +/// the change detail. For an Edit, locate the first line of `old` in `contents`; +/// for a Write (or anything not found), line 1. +pub fn resolve_line(contents: &str, detail: &ChangeDetail) -> u32 { + let needle = match detail { + ChangeDetail::Edit { old, .. } => old.lines().find(|l| !l.trim().is_empty()), + _ => None, + }; + let Some(needle) = needle else { return 1 }; + for (i, line) in contents.lines().enumerate() { + if line.contains(needle) { + return (i + 1) as u32; + } + } + 1 +} + +/// Read the file at `path` and resolve the line for `detail`. Returns 1 when +/// the file can't be read (deleted/renamed since the edit). +pub fn resolve_line_in_file(path: &Path, detail: &ChangeDetail) -> u32 { + match std::fs::read_to_string(path) { + Ok(contents) => resolve_line(&contents, detail), + Err(_) => 1, + } +} + +#[cfg(test)] +mod line_tests { + use super::*; + + #[test] + fn finds_line_of_old_string_first_line() { + let file = "fn a() {}\nfn b() {}\nfn c() {}\n"; + let detail = ChangeDetail::Edit { old: "fn b() {}".into(), new: "fn b2() {}".into() }; + assert_eq!(resolve_line(file, &detail), 2); + } + + #[test] + fn write_resolves_to_line_one() { + let detail = ChangeDetail::Write { head: "anything".into() }; + assert_eq!(resolve_line("whatever\n", &detail), 1); + } + + #[test] + fn missing_old_string_falls_back_to_line_one() { + let detail = ChangeDetail::Edit { old: "nonexistent".into(), new: "x".into() }; + assert_eq!(resolve_line("fn a() {}\n", &detail), 1); + } + + #[test] + fn none_detail_falls_back_to_line_one() { + assert_eq!(resolve_line("fn a() {}\n", &ChangeDetail::None), 1); + } +} + #[cfg(test)] mod summary_tests { use super::*; From 0961b2a1bc53f2bf2969eb0de55bb0d3e663692a Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:02:25 -0400 Subject: [PATCH 09/69] feat(chronology): enumerate all session jsonl files for a worktree --- src/activity/chronology.rs | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index b564c0e..c18bce9 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -316,6 +316,64 @@ mod line_tests { } } +/// All `.jsonl` session files under `/.claude/projects//`. +/// Testable variant taking an explicit home dir and canonical worktree path. +pub(crate) fn session_files_in(home: &Path, abs_worktree: &Path) -> Vec { + let dir = home + .join(".claude/projects") + .join(encode_cwd(abs_worktree)); + let mut files = Vec::new(); + let Ok(rd) = std::fs::read_dir(&dir) else { + return files; + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + files.push(path); + } + } + files +} + +/// Production entry point: resolve the real home dir and canonical worktree. +pub fn claude_session_files(worktree: &Path) -> Vec { + let Some(home) = dirs::home_dir() else { + return Vec::new(); + }; + let Ok(abs) = std::fs::canonicalize(worktree) else { + return Vec::new(); + }; + session_files_in(&home, &abs) +} + +#[cfg(test)] +mod locate_tests { + use super::*; + use std::io::Write; + + #[test] + fn lists_all_jsonl_files_in_session_dir() { + let home = tempfile::TempDir::new().unwrap(); + let work = tempfile::TempDir::new().unwrap(); + let abs = std::fs::canonicalize(work.path()).unwrap(); + let dir = home.path().join(".claude/projects").join(encode_cwd(&abs)); + std::fs::create_dir_all(&dir).unwrap(); + for name in ["a.jsonl", "b.jsonl", "notes.txt"] { + let mut f = std::fs::File::create(dir.join(name)).unwrap(); + writeln!(f, "{{}}").unwrap(); + } + let files = session_files_in(home.path(), &abs); + assert_eq!(files.len(), 2, "only .jsonl files counted"); + } + + #[test] + fn missing_dir_returns_empty() { + let home = tempfile::TempDir::new().unwrap(); + let abs = std::path::PathBuf::from("/nonexistent/worktree"); + assert!(session_files_in(home.path(), &abs).is_empty()); + } +} + #[cfg(test)] mod summary_tests { use super::*; From b95b943ce38a150fc4ee70aa807393ce7e53cd76 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:04:06 -0400 Subject: [PATCH 10/69] feat(chronology): parse a session file into ChangeEvents --- src/activity/chronology.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index c18bce9..3a633b3 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -316,6 +316,25 @@ mod line_tests { } } +/// Parse every line of a session file into `ChangeEvent`s. Malformed lines are +/// skipped silently (matches the existing tail-loop tolerance). +pub fn parse_file(path: &Path) -> Vec { + use std::io::{BufRead, BufReader}; + let Ok(file) = std::fs::File::open(path) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for line in BufReader::new(file).lines().map_while(|l| l.ok()) { + if line.trim().is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(&line) { + out.extend(extract_change_events(&v)); + } + } + out +} + /// All `.jsonl` session files under `/.claude/projects//`. /// Testable variant taking an explicit home dir and canonical worktree path. pub(crate) fn session_files_in(home: &Path, abs_worktree: &Path) -> Vec { @@ -374,6 +393,25 @@ mod locate_tests { } } +#[cfg(test)] +mod parse_file_tests { + use super::*; + use std::io::Write; + + #[test] + fn parses_events_from_a_jsonl_file_skipping_garbage() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("s.jsonl"); + let mut f = std::fs::File::create(&path).unwrap(); + writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-14T17:00:00.000Z","message":{{"content":[{{"type":"tool_use","name":"Write","input":{{"file_path":"/wt/x.rs","content":"pub fn x(){{}}"}}}}]}}}}"#).unwrap(); + writeln!(f, "not json at all").unwrap(); + writeln!(f, r#"{{"type":"user","message":{{"content":"hi"}}}}"#).unwrap(); + let evs = parse_file(&path); + assert_eq!(evs.len(), 1); + assert_eq!(evs[0].tool, ChangeTool::Write); + } +} + #[cfg(test)] mod summary_tests { use super::*; From 69af8493873c19b43adcaa6a7884b0f2f0b63b6e Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:06:15 -0400 Subject: [PATCH 11/69] feat(chronology): cached newest-first Timeline merging session files --- src/activity/chronology.rs | 130 +++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index 3a633b3..ff145e1 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -8,7 +8,9 @@ //! and merges them into a timeline cached by each file's `(size, mtime)`. use crate::activity::events::{encode_cwd, parse_iso8601_ms}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::time::SystemTime; /// The mutating tool that produced a change. Read and non-mutating tools are /// never recorded. @@ -365,6 +367,75 @@ pub fn claude_session_files(worktree: &Path) -> Vec { session_files_in(&home, &abs) } +/// A per-file cache key. Reparse only when size or mtime changes. +#[derive(Debug, Clone, PartialEq, Eq)] +struct FileStamp { + size: u64, + mtime: SystemTime, +} + +fn stamp(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + Some(FileStamp { + size: meta.len(), + mtime: meta.modified().ok()?, + }) +} + +/// Merged, newest-first chronology of `ChangeEvent`s across a workspace's +/// session files. Caches parsed events per file by `(size, mtime)`. +#[derive(Debug, Default)] +pub struct Timeline { + /// Per-file parsed events + the stamp they were parsed at. + per_file: HashMap)>, + /// Flattened, sorted view rebuilt on each refresh. + merged: Vec, + /// Test/diagnostic counter of how many file parses have occurred. + parses: usize, +} + +impl Timeline { + /// Re-scan `files`, reparsing only those whose `(size, mtime)` changed, + /// dropping cache entries for files no longer present, then rebuild the + /// merged newest-first view. + pub fn refresh(&mut self, files: &[PathBuf]) { + let present: std::collections::HashSet<&PathBuf> = files.iter().collect(); + self.per_file.retain(|p, _| present.contains(p)); + + for path in files { + let Some(st) = stamp(path) else { continue }; + let needs = match self.per_file.get(path) { + Some((prev, _)) => *prev != st, + None => true, + }; + if needs { + let evs = parse_file(path); + self.parses += 1; + self.per_file.insert(path.clone(), (st, evs)); + } + } + + let mut merged: Vec = self + .per_file + .values() + .flat_map(|(_, evs)| evs.iter().cloned()) + .collect(); + // Newest first; stable so same-timestamp events keep file order. + merged.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms)); + self.merged = merged; + } + + /// The merged newest-first events. + pub fn events(&self) -> &[ChangeEvent] { + &self.merged + } + + #[cfg(test)] + pub fn parse_count(&self) -> usize { + self.parses + } +} + #[cfg(test)] mod locate_tests { use super::*; @@ -447,3 +518,62 @@ mod summary_tests { assert!(s.chars().count() <= SUMMARY_MAX_CHARS); } } + +#[cfg(test)] +mod timeline_tests { + use super::*; + use std::io::Write; + + fn write_event(path: &Path, ts: &str, file: &str) { + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!( + f, + r#"{{"type":"assistant","timestamp":"{ts}","message":{{"content":[{{"type":"tool_use","name":"Write","input":{{"file_path":"{file}","content":"x"}}}}]}}}}"# + ) + .unwrap(); + } + + #[test] + fn merges_files_newest_first() { + let dir = tempfile::TempDir::new().unwrap(); + let a = dir.path().join("a.jsonl"); + let b = dir.path().join("b.jsonl"); + write_event(&a, "2026-05-14T17:00:00.000Z", "/wt/old.rs"); + write_event(&b, "2026-05-14T18:00:00.000Z", "/wt/new.rs"); + let mut tl = Timeline::default(); + tl.refresh(&[a.clone(), b.clone()]); + let evs = tl.events(); + assert_eq!(evs.len(), 2); + assert_eq!(evs[0].file_path, PathBuf::from("/wt/new.rs"), "newest first"); + assert_eq!(evs[1].file_path, PathBuf::from("/wt/old.rs")); + } + + #[test] + fn unchanged_file_is_not_reparsed() { + let dir = tempfile::TempDir::new().unwrap(); + let a = dir.path().join("a.jsonl"); + write_event(&a, "2026-05-14T17:00:00.000Z", "/wt/old.rs"); + let mut tl = Timeline::default(); + tl.refresh(&[a.clone()]); + assert_eq!(tl.parse_count(), 1); + tl.refresh(&[a.clone()]); // same size+mtime → cache hit + assert_eq!(tl.parse_count(), 1, "should not reparse unchanged file"); + } + + #[test] + fn grown_file_is_reparsed() { + let dir = tempfile::TempDir::new().unwrap(); + let a = dir.path().join("a.jsonl"); + write_event(&a, "2026-05-14T17:00:00.000Z", "/wt/old.rs"); + let mut tl = Timeline::default(); + tl.refresh(&[a.clone()]); + write_event(&a, "2026-05-14T19:00:00.000Z", "/wt/newer.rs"); + tl.refresh(&[a.clone()]); + assert_eq!(tl.parse_count(), 2, "size changed → reparse"); + assert_eq!(tl.events().len(), 2); + } +} From 806af67be7e11c398d964b6ac08c247380e3458c Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:09:43 -0400 Subject: [PATCH 12/69] fix(chronology): deterministic tie-break in Timeline sort; cache-removal test --- src/activity/chronology.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index ff145e1..79da62c 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -420,8 +420,14 @@ impl Timeline { .values() .flat_map(|(_, evs)| evs.iter().cloned()) .collect(); - // Newest first; stable so same-timestamp events keep file order. - merged.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms)); + // Newest first. Tie-break on file_path so equal-timestamp events have a + // deterministic order across refreshes (the per_file source is a HashMap + // whose iteration order is not stable). + merged.sort_by(|a, b| { + b.timestamp_ms + .cmp(&a.timestamp_ms) + .then_with(|| a.file_path.cmp(&b.file_path)) + }); self.merged = merged; } @@ -576,4 +582,21 @@ mod timeline_tests { assert_eq!(tl.parse_count(), 2, "size changed → reparse"); assert_eq!(tl.events().len(), 2); } + + #[test] + fn removed_file_events_drop_from_merged() { + let dir = tempfile::TempDir::new().unwrap(); + let a = dir.path().join("a.jsonl"); + let b = dir.path().join("b.jsonl"); + write_event(&a, "2026-05-14T17:00:00.000Z", "/wt/a.rs"); + write_event(&b, "2026-05-14T18:00:00.000Z", "/wt/b.rs"); + let mut tl = Timeline::default(); + tl.refresh(&[a.clone(), b.clone()]); + assert_eq!(tl.events().len(), 2); + // b.jsonl no longer in the file list → its events must disappear. + tl.refresh(&[a.clone()]); + let evs = tl.events(); + assert_eq!(evs.len(), 1); + assert_eq!(evs[0].file_path, PathBuf::from("/wt/a.rs")); + } } From 638db4886ef268b6872aea8675ce143f151bb346 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:14:38 -0400 Subject: [PATCH 13/69] feat(chronology): config struct + repos.chronology_config column Add `ChronologyConfig` / `ChronologyOverride` / `WidthSpec` / `Side` types in `src/config/chronology.rs` with per-field merge, `sanitize()` clamping, and `resolved_width()`. Wire the `repos.chronology_config` TEXT column via schema migration v13, a new `set_repo_chronology_config` setter, and an updated SELECT in `repos()` (indices 0-12). Sweep all `Repo { .. }` struct literals to add `chronology_config: None`. --- src/agent/related.rs | 1 + src/config/chronology.rs | 183 ++++++++++++++++++++++++++++++++ src/config/detail_bar_config.rs | 1 + src/config/mod.rs | 1 + src/data/repo.rs | 1 + src/data/store.rs | 42 +++++++- src/detail_modules/mod.rs | 1 + src/ui/dashboard/tests.rs | 1 + 8 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 src/config/chronology.rs diff --git a/src/agent/related.rs b/src/agent/related.rs index 13aabe0..30f1af3 100644 --- a/src/agent/related.rs +++ b/src/agent/related.rs @@ -89,6 +89,7 @@ mod tests { related_repos: None, base_branch: None, detail_bar_config: None, + chronology_config: None, created_at: 0, } } diff --git a/src/config/chronology.rs b/src/config/chronology.rs new file mode 100644 index 0000000..3e64cc9 --- /dev/null +++ b/src/config/chronology.rs @@ -0,0 +1,183 @@ +//! Display config for the change-chronology bar. Resolved from a global JSON +//! blob in `settings` (`chronology_config`) + a per-repo JSON override on +//! `repos.chronology_config`. Scalar fields merge per-field; repo wins. +//! Mirrors `src/config/detail_bar_config.rs`. +//! +//! See `docs/superpowers/specs/2026-06-05-change-chronology-view-design.md`. + +use crate::data::store::{Repo, Store}; +use serde::{Deserialize, Serialize}; + +fn default_visible() -> bool { true } +fn default_percent() -> u8 { 32 } +fn default_min_cols() -> u16 { 24 } +fn default_max_cols() -> u16 { 60 } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Side { Left, Right } + +impl Default for Side { + fn default() -> Self { Side::Right } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WidthSpec { + #[serde(default = "default_percent")] + pub percent: u8, + #[serde(default = "default_min_cols")] + pub min_cols: u16, + #[serde(default = "default_max_cols")] + pub max_cols: u16, +} + +impl Default for WidthSpec { + fn default() -> Self { + Self { + percent: default_percent(), + min_cols: default_min_cols(), + max_cols: default_max_cols(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChronologyConfig { + #[serde(default = "default_visible")] + pub visible: bool, + #[serde(default)] + pub side: Side, + #[serde(default)] + pub width: WidthSpec, +} + +impl Default for ChronologyConfig { + fn default() -> Self { + Self { + visible: default_visible(), + side: Side::default(), + width: WidthSpec::default(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChronologyOverride { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visible: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, +} + +impl ChronologyConfig { + pub fn with_override(mut self, ovr: &ChronologyOverride) -> Self { + if let Some(v) = ovr.visible { + self.visible = v; + } + if let Some(s) = ovr.side { + self.side = s; + } + if let Some(w) = &ovr.width { + self.width = w.clone(); + } + self + } + + /// Clamp into legal ranges and swap inverted min/max. Idempotent. + pub fn sanitize(&mut self) { + self.width.percent = self.width.percent.clamp(10, 80); + self.width.min_cols = self.width.min_cols.clamp(12, 120); + self.width.max_cols = self.width.max_cols.clamp(12, 160); + if self.width.min_cols > self.width.max_cols { + std::mem::swap(&mut self.width.min_cols, &mut self.width.max_cols); + } + } + + /// Column width for an attach area `total` columns wide: `percent` of + /// `total`, clamped to `[min_cols, max_cols]`. + pub fn resolved_width(&self, total: u16) -> u16 { + let target = (u32::from(total) * u32::from(self.width.percent) / 100) as u16; + target.clamp(self.width.min_cols, self.width.max_cols) + } +} + +/// Resolve the global config only (no repo override). Defaults on missing key +/// or parse failure. Mirrors `detail_bar_config::resolve_global_only`. +pub fn resolve_global_only(store: &Store) -> ChronologyConfig { + let mut cfg = match store.get_setting("chronology_config") { + Ok(Some(raw)) => serde_json::from_str(&raw).unwrap_or_else(|e| { + tracing::warn!(error = %e, "chronology_config: global parse failed; using defaults"); + ChronologyConfig::default() + }), + _ => ChronologyConfig::default(), + }; + cfg.sanitize(); + cfg +} + +/// Resolve global config with the per-repo override applied. Mirrors +/// `detail_bar_config::resolve`. +pub fn resolve(repo: &Repo, store: &Store) -> ChronologyConfig { + let mut cfg = resolve_global_only(store); + if let Some(raw) = repo.chronology_config.as_deref() { + match serde_json::from_str::(raw) { + Ok(ovr) => cfg = cfg.with_override(&ovr), + Err(e) => { + tracing::warn!(error = %e, "chronology_config: repo override parse failed; ignoring"); + } + } + } + cfg.sanitize(); + cfg +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_visible_right_sane_width() { + let c = ChronologyConfig::default(); + assert!(c.visible); + assert_eq!(c.side, Side::Right); + assert_eq!(c.width.percent, 32); + assert!(c.width.min_cols <= c.width.max_cols); + } + + #[test] + fn override_merges_per_field() { + let base = ChronologyConfig::default(); + let ovr = ChronologyOverride { + visible: Some(false), + side: Some(Side::Left), + width: None, + }; + let merged = base.with_override(&ovr); + assert!(!merged.visible); + assert_eq!(merged.side, Side::Left); + assert_eq!(merged.width.percent, 32, "unspecified width inherits"); + } + + #[test] + fn sanitize_clamps_and_swaps() { + let mut c = ChronologyConfig::default(); + c.width.percent = 99; + c.width.min_cols = 80; + c.width.max_cols = 10; + c.sanitize(); + assert!(c.width.percent <= 80); + assert!(c.width.min_cols <= c.width.max_cols, "inverted min/max swapped"); + } + + #[test] + fn resolved_width_clamps_to_min_and_max() { + let mut c = ChronologyConfig::default(); + c.width.percent = 50; + c.width.min_cols = 20; + c.width.max_cols = 30; + assert_eq!(c.resolved_width(200), 30, "50% of 200 = 100, clamped to max 30"); + assert_eq!(c.resolved_width(20), 20, "50% of 20 = 10, clamped to min 20"); + } +} diff --git a/src/config/detail_bar_config.rs b/src/config/detail_bar_config.rs index 63d85c1..5a2a0d6 100644 --- a/src/config/detail_bar_config.rs +++ b/src/config/detail_bar_config.rs @@ -231,6 +231,7 @@ mod tests { related_repos: None, base_branch: None, detail_bar_config: detail_bar_config.map(|s| s.to_string()), + chronology_config: None, created_at: 0, } } diff --git a/src/config/mod.rs b/src/config/mod.rs index ba8bc5b..b102cd7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -4,6 +4,7 @@ //! `detail_bar_config` submodule resolves the workspace detail-bar display //! config from global + per-repo JSON. +pub mod chronology; pub mod detail_bar_config; pub mod usage_window; diff --git a/src/data/repo.rs b/src/data/repo.rs index 1f5c208..54c9bb4 100644 --- a/src/data/repo.rs +++ b/src/data/repo.rs @@ -107,6 +107,7 @@ mod settings_tests { related_repos: None, base_branch: None, detail_bar_config: None, + chronology_config: None, created_at: 0, } } diff --git a/src/data/store.rs b/src/data/store.rs index 9a3fe91..e91d33e 100644 --- a/src/data/store.rs +++ b/src/data/store.rs @@ -45,6 +45,7 @@ pub struct Repo { pub related_repos: Option, pub base_branch: Option, pub detail_bar_config: Option, + pub chronology_config: Option, pub created_at: i64, } @@ -235,6 +236,18 @@ impl Store { )?; self.conn.execute("PRAGMA user_version = 12", [])?; } + if v < 13 { + let has_chronology: i64 = self.conn.query_row( + "SELECT count(*) FROM pragma_table_info('repos') WHERE name = 'chronology_config'", + [], + |r| r.get(0), + )?; + if has_chronology == 0 { + self.conn + .execute("ALTER TABLE repos ADD COLUMN chronology_config TEXT", [])?; + } + self.conn.execute("PRAGMA user_version = 13", [])?; + } Ok(()) } @@ -274,7 +287,8 @@ impl Store { let mut stmt = self.conn.prepare( "SELECT id, name, path, branch_prefix, custom_instructions, \ setup_script, archive_script, pinned_commands, \ - related_repos, base_branch, detail_bar_config, created_at \ + related_repos, base_branch, detail_bar_config, \ + chronology_config, created_at \ FROM repos ORDER BY id", )?; let rows = stmt.query_map([], |r| { @@ -290,7 +304,8 @@ impl Store { related_repos: r.get(8)?, base_branch: r.get(9)?, detail_bar_config: r.get(10)?, - created_at: r.get(11)?, + chronology_config: r.get(11)?, + created_at: r.get(12)?, }) })?; Ok(rows.collect::>()?) @@ -430,6 +445,14 @@ impl Store { Ok(()) } + pub fn set_repo_chronology_config(&self, id: RepoId, value: Option<&str>) -> Result<()> { + self.conn.execute( + "UPDATE repos SET chronology_config = ?1 WHERE id = ?2", + rusqlite::params![value, id.0], + )?; + Ok(()) + } + pub fn get_setting(&self, key: &str) -> Result> { self.conn .query_row("SELECT value FROM settings WHERE key = ?1", [key], |r| { @@ -1064,6 +1087,19 @@ mod tests { assert!(repo.detail_bar_config.is_none()); } + #[test] + fn chronology_config_column_round_trips() { + let store = Store::open_in_memory().unwrap(); + let id = store.add_repo(Path::new("/tmp/r"), "r", "wsx/").unwrap(); + let repo = store.repos().unwrap().into_iter().find(|r| r.id == id).unwrap(); + assert!(repo.chronology_config.is_none()); + store + .set_repo_chronology_config(id, Some(r#"{"visible":false}"#)) + .unwrap(); + let repo = store.repos().unwrap().into_iter().find(|r| r.id == id).unwrap(); + assert_eq!(repo.chronology_config.as_deref(), Some(r#"{"visible":false}"#)); + } + #[test] fn activity_bucket_round_trip_and_prune() { let store = Store::open_in_memory().unwrap(); @@ -1810,6 +1846,6 @@ mod tests { .conn() .query_row("PRAGMA user_version", [], |r| r.get(0)) .unwrap(); - assert_eq!(v, 12); + assert_eq!(v, 13); } } diff --git a/src/detail_modules/mod.rs b/src/detail_modules/mod.rs index 10f54ae..1612e82 100644 --- a/src/detail_modules/mod.rs +++ b/src/detail_modules/mod.rs @@ -123,6 +123,7 @@ pub(crate) mod tests_helpers { related_repos: None, base_branch: None, detail_bar_config: None, + chronology_config: None, created_at: 0, })); let workspace: &'static Workspace = Box::leak(Box::new(Workspace { diff --git a/src/ui/dashboard/tests.rs b/src/ui/dashboard/tests.rs index d3ffc1b..3985ff3 100644 --- a/src/ui/dashboard/tests.rs +++ b/src/ui/dashboard/tests.rs @@ -23,6 +23,7 @@ fn fake_repo(id: i64, name: &str, path: &str) -> Repo { related_repos: None, base_branch: None, detail_bar_config: None, + chronology_config: None, created_at: 0, } } From 8801d00258f45fa27a906ef977514fe521d6aefc Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:19:41 -0400 Subject: [PATCH 14/69] feat(cli): chronology_config config key (validate/normalize/seed) --- src/cli.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index d04e884..43be813 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -398,6 +398,7 @@ fn known_setting_key(k: &str) -> bool { | "coding_agent" | "detail_bar_config" | "usage_graph_window" + | "chronology_config" ) } @@ -1178,6 +1179,8 @@ pub async fn run_cli(action: CliAction, dirs: &Dirs) -> Result<()> { detail_bar_config_validate_and_normalize(&value)? } else if key == "usage_graph_window" { usage_window_validate_and_normalize(&value)? + } else if key == "chronology_config" { + chronology_config_validate_and_normalize(&value)? } else { value }; @@ -1204,6 +1207,8 @@ pub async fn run_cli(action: CliAction, dirs: &Dirs) -> Result<()> { let current = store.get_setting(&key)?.unwrap_or_default(); let seed = if key == "detail_bar_config" && current.is_empty() { detail_bar_config_seed_for_empty() + } else if key == "chronology_config" && current.is_empty() { + chronology_config_seed_for_empty() } else { current.clone() }; @@ -1219,6 +1224,8 @@ pub async fn run_cli(action: CliAction, dirs: &Dirs) -> Result<()> { detail_bar_config_validate_and_normalize(&new_value)? } else if key == "usage_graph_window" { usage_window_validate_and_normalize(&new_value)? + } else if key == "chronology_config" { + chronology_config_validate_and_normalize(&new_value)? } else { new_value.clone() }; @@ -1472,6 +1479,23 @@ fn detail_bar_config_validate_and_normalize(raw: &str) -> Result { .map_err(|e| Error::UserInput(format!("detail_bar_config: serialize failed: {e}"))) } +/// Seed text for the editor when the global `chronology_config` +/// setting is empty — the pretty-printed default config. +fn chronology_config_seed_for_empty() -> String { + serde_json::to_string_pretty(&crate::config::chronology::ChronologyConfig::default()) + .unwrap_or_else(|_| "{}".to_string()) +} + +/// Parse, sanitize, and re-serialize a global `chronology_config` +/// blob. Returns the pretty-printed normalized JSON. +fn chronology_config_validate_and_normalize(raw: &str) -> Result { + let mut cfg: crate::config::chronology::ChronologyConfig = serde_json::from_str(raw) + .map_err(|e| Error::UserInput(format!("chronology_config: invalid JSON: {e}")))?; + cfg.sanitize(); + serde_json::to_string_pretty(&cfg) + .map_err(|e| Error::UserInput(format!("chronology_config: serialize failed: {e}"))) +} + /// Validate a `usage_graph_window` value: accept only the canonical tokens /// (`24h`/`1w`/`1mo`), ignoring surrounding whitespace, and store the trimmed /// canonical form. Rejects anything else so a CLI typo fails loudly instead of @@ -2158,6 +2182,23 @@ mod tests { assert!(known_setting_key("process_doctrine")); } + #[test] + fn chronology_config_validate_accepts_partial_json() { + let out = super::chronology_config_validate_and_normalize(r#"{"side":"left"}"#).unwrap(); + assert!(out.contains("\"side\"")); + } + + #[test] + fn chronology_config_validate_rejects_bad_json() { + assert!(super::chronology_config_validate_and_normalize("{not json").is_err()); + } + + #[test] + fn chronology_config_seed_is_valid_json() { + let seed = super::chronology_config_seed_for_empty(); + assert!(serde_json::from_str::(&seed).is_ok()); + } + #[test] fn parses_agent_send_joins_prompt() { match parse(&["agent", "send", "claude#2", "hello", "there"]).unwrap() { From 27b9d9232753005e9231e6f6e36e51ff45b862f7 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:22:57 -0400 Subject: [PATCH 15/69] feat(chronology): pure render helpers for the chronology bar --- src/ui/chronology_bar.rs | 129 +++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + 2 files changed, 130 insertions(+) create mode 100644 src/ui/chronology_bar.rs diff --git a/src/ui/chronology_bar.rs b/src/ui/chronology_bar.rs new file mode 100644 index 0000000..dcca1db --- /dev/null +++ b/src/ui/chronology_bar.rs @@ -0,0 +1,129 @@ +//! Pure rendering helpers for the change-chronology bar. The host +//! (`src/ui/attached.rs`) carves the side column and calls these to build the +//! content lines; keeping the formatting pure makes it unit-testable. + +use crate::activity::chronology::{ChangeDetail, ChangeEvent}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use std::path::Path; + +/// Minimum columns the agent pane must keep for the bar to be allowed. +pub const MIN_AGENT_COLS: u16 = 40; + +/// Worktree-relative display path, falling back to the full path when the file +/// is not under the worktree. +pub fn relative_display(file: &Path, worktree: &Path) -> String { + match file.strip_prefix(worktree) { + Ok(rel) => rel.to_string_lossy().to_string(), + Err(_) => file.to_string_lossy().to_string(), + } +} + +/// Hide the bar when carving `bar_cols` would leave the agent < MIN_AGENT_COLS. +pub fn should_auto_hide(area_cols: u16, bar_cols: u16) -> bool { + area_cols.saturating_sub(bar_cols) < MIN_AGENT_COLS +} + +fn hhmm(timestamp_ms: i64) -> String { + // Wall-clock HH:MM (UTC) derived from epoch ms without pulling in chrono — + // a relative glance, not a precise local timestamp. Matches the + // chrono-free style of activity/events.rs. + let secs = timestamp_ms.div_euclid(1000); + let h = secs.div_euclid(3600).rem_euclid(24); + let m = secs.div_euclid(60).rem_euclid(60); + format!("{h:02}:{m:02}") +} + +/// Render one entry into lines. Line 1: `HH:MM file`. Line 2: dim summary. +/// When `expanded`, appends up to a few diff-peek lines from `detail`. +pub fn entry_lines( + ev: &ChangeEvent, + worktree: &Path, + expanded: bool, + width: u16, +) -> Vec> { + let mut out = Vec::new(); + let rel = relative_display(&ev.file_path, worktree); + out.push(Line::from(vec![ + Span::styled(hhmm(ev.timestamp_ms), Style::default().add_modifier(Modifier::DIM)), + Span::raw(" "), + Span::raw(rel), + ])); + out.push(Line::from(Span::styled( + ev.summary.clone(), + Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC), + ))); + if expanded { + let peek: Vec = match &ev.detail { + ChangeDetail::Edit { old, new } => { + let mut v = Vec::new(); + for l in old.lines().take(2) { + v.push(format!("- {l}")); + } + for l in new.lines().take(2) { + v.push(format!("+ {l}")); + } + v + } + ChangeDetail::Write { head } => { + head.lines().take(3).map(|l| format!("+ {l}")).collect() + } + ChangeDetail::None => Vec::new(), + }; + for l in peek { + let clipped: String = l.chars().take(width as usize).collect(); + out.push(Line::from(Span::styled( + clipped, + Style::default().add_modifier(Modifier::DIM), + ))); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::activity::chronology::ChangeTool; + use std::path::PathBuf; + + fn ev(file: &str, summary: &str) -> ChangeEvent { + ChangeEvent { + timestamp_ms: 0, + tool: ChangeTool::Edit, + file_path: PathBuf::from(file), + summary: summary.to_string(), + detail: ChangeDetail::Edit { old: "a".into(), new: "b".into() }, + } + } + + #[test] + fn relative_path_strips_worktree_prefix() { + let p = relative_display(Path::new("/wt/src/main.rs"), Path::new("/wt")); + assert_eq!(p, "src/main.rs"); + } + + #[test] + fn relative_path_passthrough_when_not_prefixed() { + let p = relative_display(Path::new("/other/x.rs"), Path::new("/wt")); + assert_eq!(p, "/other/x.rs"); + } + + #[test] + fn auto_hide_when_area_too_narrow() { + assert!(should_auto_hide(35, 30)); + assert!(!should_auto_hide(120, 30)); + } + + #[test] + fn entry_produces_header_and_summary_lines() { + let lines = entry_lines(&ev("/wt/src/main.rs", "fn foo()"), Path::new("/wt"), false, 40); + assert_eq!(lines.len(), 2, "B fidelity: header + summary, no diff peek"); + } + + #[test] + fn expanded_entry_adds_diff_peek_lines() { + let lines = entry_lines(&ev("/wt/src/main.rs", "fn foo()"), Path::new("/wt"), true, 40); + assert!(lines.len() > 2, "expanded entry includes diff peek"); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 533d5f8..98f77a1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod attached; +pub mod chronology_bar; pub mod dashboard; pub mod modal; pub mod pm_pane; From 77b75ef2b84a0e67374ebc222ccb204edb2e0888 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:25:07 -0400 Subject: [PATCH 16/69] feat(chronology): app state + refresh helper for timelines --- src/app.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/app.rs b/src/app.rs index 1731b7b..cc55954 100644 --- a/src/app.rs +++ b/src/app.rs @@ -155,6 +155,21 @@ pub struct App { crate::data::store::WorkspaceId, crate::activity::events::WorkspaceEvents, >, + /// Per-workspace change-chronology timelines, keyed by workspace id. + /// Lazily built/refreshed while attached. + pub chronology: std::collections::HashMap< + crate::data::store::WorkspaceId, + crate::activity::chronology::Timeline, + >, + /// Scroll offset (entries from the top) of the chronology bar in the + /// focused attached pane. + pub chronology_scroll: usize, + /// Index of the currently expanded chronology entry, if any. + pub chronology_expanded: Option, + /// Transient per-draw hit-test rects for chronology entries in the focused + /// pane: `(entry_index, rect)`. Rebuilt each frame by the attached renderer + /// and consumed by the input handler. Empty when the bar isn't shown. + pub chronology_entry_rects: Vec<(usize, ratatui::layout::Rect)>, /// Per-workspace tracking for attention-alert state. pub workspace_activity: std::collections::HashMap, @@ -304,6 +319,10 @@ impl App { pr_last_poll_ms: std::collections::HashMap::new(), diff_last_poll_ms: std::collections::HashMap::new(), workspace_events: std::collections::HashMap::new(), + chronology: std::collections::HashMap::new(), + chronology_scroll: 0, + chronology_expanded: None, + chronology_entry_rects: Vec::new(), workspace_activity: std::collections::HashMap::new(), workspace_events_scanned: std::collections::HashSet::new(), workspace_needs_attention: std::collections::HashSet::new(), @@ -426,6 +445,20 @@ impl App { g } + /// Refresh the chronology timeline for `worktree`/`workspace_id` from the + /// on-disk session logs. Cheap when nothing changed (per-file cache). + pub fn refresh_chronology( + &mut self, + workspace_id: crate::data::store::WorkspaceId, + worktree: &std::path::Path, + ) { + let files = crate::activity::chronology::claude_session_files(worktree); + self.chronology + .entry(workspace_id) + .or_default() + .refresh(&files); + } + pub fn selected_target(&self) -> Option { self.selectable.get(self.dashboard.selected).copied() } From 7d828c69165dbee53d13c6de10ea9911b855dfc2 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:32:33 -0400 Subject: [PATCH 17/69] feat(chronology): carve side column and render the bar in attached view --- src/app/render.rs | 72 +++++++++++++- src/ui/attached.rs | 239 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+), 1 deletion(-) diff --git a/src/app/render.rs b/src/app/render.rs index aad6306..f4309e0 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -34,6 +34,29 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { app.agent_chip_rects.clear(); app.usage_graph_rect = None; app.usage_window_option_rects.clear(); + + // Refresh the focused workspace's change-chronology timeline before the + // render borrow of `app.view` is taken below. `refresh_chronology` is the + // only `&mut app` the attached chronology bar needs; doing it here (in a + // short scope that drops the `&app.view` borrow first) keeps the render + // arm's immutable borrows of `app` conflict-free. + { + let focused: Option<(crate::data::store::WorkspaceId, std::path::PathBuf)> = + if let crate::ui::View::Attached(state) = &app.view { + state.focused_target().and_then(|t| { + app.workspaces + .iter() + .find(|(_, w)| w.id == t.workspace_id) + .map(|(_, w)| (t.workspace_id, w.worktree_path.clone())) + }) + } else { + None + }; + if let Some((ws_id, worktree)) = focused { + app.refresh_chronology(ws_id, &worktree); + } + } + match &app.view { crate::ui::View::Dashboard => { let selection_is_workspace = @@ -494,7 +517,51 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { }) .unwrap_or_default(); let attention_line = attention.map(|a| a.line); - let crate::ui::split::LayoutResult { panes, dividers } = state.layout(pane_area); + + // Change-chronology bar for the focused workspace. The timeline was + // already refreshed before this match (the only `&mut app` the bar + // needs). Resolve the focused workspace's worktree + repo config, + // then clone events + worktree into locals so the `ChronologyDraw` + // borrows nothing from `app` — leaving the final `&mut app` write of + // the entry rects unobstructed. + let chronology_worktree: Option = app + .workspaces + .iter() + .find(|(_, w)| w.id == focused_id) + .map(|(_, w)| w.worktree_path.clone()); + let chronology_cfg: Option = app + .workspaces + .iter() + .find(|(_, w)| w.id == focused_id) + .and_then(|(rid, _)| app.repos.iter().find(|r| r.id == *rid)) + .map(|repo| crate::config::chronology::resolve(repo, &app.store)); + let chronology_events: Vec = app + .chronology + .get(&focused_id) + .map(|t| t.events().to_vec()) + .unwrap_or_default(); + let chronology_scroll = app.chronology_scroll; + let chronology_expanded = app.chronology_expanded; + + // Build the draw data (borrows only the locals above) and carve the + // bar's side column out of `pane_area` BEFORE laying out the panes, + // so the agent PTYs get the narrowed area and never draw under the + // bar. `split_for_chronology` returns `None` for the bar when the + // config is absent/hidden or the area is too narrow. + let chronology_draw = match (chronology_cfg.as_ref(), chronology_worktree.as_deref()) { + (Some(config), Some(worktree)) => Some(crate::ui::attached::ChronologyDraw { + config, + events: &chronology_events, + worktree, + scroll: chronology_scroll, + expanded: chronology_expanded, + }), + _ => None, + }; + let (agent_pane_area, bar_rect) = + attached::split_for_chronology(pane_area, &chronology_draw); + + let crate::ui::split::LayoutResult { panes, dividers } = state.layout(agent_pane_area); let multi_pane = panes.len() > 1; // The agent instance in the focused pane is the "active" one; the @@ -564,12 +631,14 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { &pinned, &focused_agents_list, active_agent, + bar_rect.zip(chronology_draw), &app.theme, ); app.chip_rects = out.chip_rects; app.attention_rects = attention_rects; app.attached_pane_rects = out.pane_rects; app.agent_chip_rects = out.agent_chip_rects; + app.chronology_entry_rects = out.chronology_entry_rects; app.pinned_commands_cache = pinned; } crate::ui::View::AttachedPm => { @@ -631,6 +700,7 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { pinned, &[], None, + None, &app.theme, ); app.attached_pane_rects = out.pane_rects; diff --git a/src/ui/attached.rs b/src/ui/attached.rs index 092a4c4..4f39254 100644 --- a/src/ui/attached.rs +++ b/src/ui/attached.rs @@ -1,4 +1,5 @@ use crate::commands::pinned::{PinnedCommand, truncate_label}; +use crate::config::chronology::Side; use crate::data::store::AgentInstanceId; use crate::pty::render::render_screen; use crate::pty::session::{AgentKind, Session}; @@ -24,6 +25,16 @@ pub struct PaneSpec<'a> { pub agent: Option, } +/// Everything `render_panes` needs to draw the chronology bar for the focused +/// pane. `None` at the call site means the bar is disabled/hidden. +pub struct ChronologyDraw<'a> { + pub config: &'a crate::config::chronology::ChronologyConfig, + pub events: &'a [crate::activity::chronology::ChangeEvent], + pub worktree: &'a std::path::Path, + pub scroll: usize, + pub expanded: Option, +} + /// What `render_panes` reports back to the caller for input hit-testing. pub struct PanesDrawOutput { /// Clickable rects of the pinned-command chips (same as before). @@ -34,6 +45,52 @@ pub struct PanesDrawOutput { /// agents row. Empty when the row isn't shown. Consumed by the input /// handler to retarget the focused pane on click. pub agent_chip_rects: Vec<(AgentInstanceId, Rect)>, + /// `(entry_index, clickable_rect)` for each rendered chronology entry in the + /// focused pane's bar. Empty when the bar isn't shown. + pub chronology_entry_rects: Vec<(usize, Rect)>, +} + +/// Split `area` into `(agent_area, Some(bar_rect))` per the chronology config, +/// or `(area, None)` when disabled/auto-hidden. Pure rect math — shared by the +/// caller (which carves `pane_area` before `SplitTree::layout`) and the +/// unit tests. +pub fn split_for_chronology(area: Rect, draw: &Option>) -> (Rect, Option) { + let Some(draw) = draw else { + return (area, None); + }; + if !draw.config.visible { + return (area, None); + } + let bar_cols = draw.config.resolved_width(area.width); + if crate::ui::chronology_bar::should_auto_hide(area.width, bar_cols) { + return (area, None); + } + match draw.config.side { + Side::Right => { + let agent = Rect { + width: area.width - bar_cols, + ..area + }; + let bar = Rect { + x: area.x + area.width - bar_cols, + width: bar_cols, + ..area + }; + (agent, Some(bar)) + } + Side::Left => { + let bar = Rect { + width: bar_cols, + ..area + }; + let agent = Rect { + x: area.x + bar_cols, + width: area.width - bar_cols, + ..area + }; + (agent, Some(bar)) + } + } } /// Render one or more attached panes plus the shared chrome (optional @@ -68,6 +125,7 @@ pub fn render_panes( pinned: &[PinnedCommand], agents: &[(AgentInstanceId, AgentKind, String, Option)], active_agent: Option, + chronology_bar: Option<(Rect, ChronologyDraw<'_>)>, theme: &Theme, ) -> PanesDrawOutput { let show_titles = panes.len() > 1; @@ -80,6 +138,11 @@ pub fn render_panes( render_dividers(f, dividers, theme); + let chronology_entry_rects = match chronology_bar { + Some((bar_rect, draw)) => render_chronology_bar(f, bar_rect, &draw, theme), + None => Vec::new(), + }; + if let Some(line) = attention_line { f.render_widget(Paragraph::new(line), status_area); } @@ -113,7 +176,136 @@ pub fn render_panes( chip_rects, pane_rects, agent_chip_rects, + chronology_entry_rects, + } +} + +/// Paint the change-chronology bar into `bar_rect` (absolute screen coords): +/// a 1-col divider on the bar's inner edge, a `CHANGE CHRONOLOGY` header with a +/// side indicator, then the timeline entries from `draw.scroll` down. Returns +/// `(entry_index, header_rect)` for each rendered entry's first (header) line, +/// in absolute screen coords, for later click hit-testing. Entries that don't +/// fit vertically are dropped from the end. +fn render_chronology_bar( + f: &mut Frame, + bar_rect: Rect, + draw: &ChronologyDraw<'_>, + theme: &Theme, +) -> Vec<(usize, Rect)> { + if bar_rect.width == 0 || bar_rect.height == 0 { + return Vec::new(); + } + + // The divider occupies the column on the bar's inner edge (next to the + // agent pane): the bar's left edge when it sits on the right, the bar's + // right edge when it sits on the left. + let on_right = matches!(draw.config.side, Side::Right); + let divider_x = if on_right { + bar_rect.x + } else { + bar_rect.x + bar_rect.width.saturating_sub(1) + }; + let divider_style = Style::default().fg(theme.path); + { + let buf = f.buffer_mut(); + for y in bar_rect.y..bar_rect.y.saturating_add(bar_rect.height) { + if buf.area().contains((divider_x, y).into()) { + buf[(divider_x, y)].set_symbol("│").set_style(divider_style); + } + } + } + + // Content area: the bar minus its inner-edge divider column. + let content = if on_right { + Rect { + x: bar_rect.x.saturating_add(1), + width: bar_rect.width.saturating_sub(1), + ..bar_rect + } + } else { + Rect { + width: bar_rect.width.saturating_sub(1), + ..bar_rect + } + }; + if content.width == 0 || content.height == 0 { + return Vec::new(); } + let inner_width = content.width; + + // Header line: `CHANGE CHRONOLOGY ◀/▶`, truncated to the content width. + let side_glyph = if on_right { "◀" } else { "▶" }; + let header_full = format!("CHANGE CHRONOLOGY {side_glyph}"); + let header_text: String = header_full.chars().take(inner_width as usize).collect(); + let header_area = Rect { + height: 1, + ..content + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled(header_text, theme.header_style()))), + header_area, + ); + + // Body starts one row below the header. + let body_y = content.y.saturating_add(1); + let body_bottom = content.y.saturating_add(content.height); + if body_y >= body_bottom { + return Vec::new(); + } + + let mut entry_rects: Vec<(usize, Rect)> = Vec::new(); + + if draw.events.is_empty() { + let placeholder = Rect { + x: content.x, + y: body_y, + width: inner_width, + height: 1, + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled( + "—".to_string(), + Style::default().add_modifier(Modifier::DIM), + ))), + placeholder, + ); + return entry_rects; + } + + let mut cursor_y = body_y; + for (i, ev) in draw.events.iter().enumerate().skip(draw.scroll) { + if cursor_y >= body_bottom { + break; + } + let expanded = Some(i) == draw.expanded; + let lines = + crate::ui::chronology_bar::entry_lines(ev, draw.worktree, expanded, inner_width); + let available = body_bottom.saturating_sub(cursor_y); + let drawn = (lines.len() as u16).min(available); + if drawn == 0 { + break; + } + let entry_area = Rect { + x: content.x, + y: cursor_y, + width: inner_width, + height: drawn, + }; + f.render_widget(Paragraph::new(lines), entry_area); + // The first (header) line is the clickable hit target for this entry. + entry_rects.push(( + i, + Rect { + x: content.x, + y: cursor_y, + width: inner_width, + height: 1, + }, + )); + cursor_y = cursor_y.saturating_add(drawn); + } + + entry_rects } fn render_one_pane(f: &mut Frame, pane: &PaneSpec<'_>, show_title: bool, theme: &Theme) -> Rect { @@ -561,6 +753,53 @@ mod tests { .collect() } + #[test] + fn split_right_carves_bar_on_right() { + let cfg = crate::config::chronology::ChronologyConfig::default(); + let events: Vec = Vec::new(); + let draw = ChronologyDraw { + config: &cfg, + events: &events, + worktree: std::path::Path::new("/wt"), + scroll: 0, + expanded: None, + }; + let area = Rect { + x: 0, + y: 0, + width: 200, + height: 50, + }; + let (agent, bar) = split_for_chronology(area, &Some(draw)); + let bar = bar.expect("bar shown at 200 cols"); + assert_eq!(agent.width + bar.width, 200); + assert!(bar.x > agent.x, "right side"); + } + + #[test] + fn split_hidden_when_too_narrow() { + let cfg = crate::config::chronology::ChronologyConfig::default(); + let events: Vec = Vec::new(); + let draw = ChronologyDraw { + config: &cfg, + events: &events, + worktree: std::path::Path::new("/wt"), + scroll: 0, + expanded: None, + }; + let area = Rect { + x: 0, + y: 0, + width: 50, + height: 50, + }; + let (_agent, bar) = split_for_chronology(area, &Some(draw)); + assert!( + bar.is_none(), + "auto-hidden when agent would be < MIN_AGENT_COLS" + ); + } + #[test] fn chip_row_layout_returns_rects_for_each_visible_chip() { let area = ratatui::layout::Rect::new(0, 0, 80, 1); From b30c8bced52c97739da323eaaf8841ed7938dd78 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:38:41 -0400 Subject: [PATCH 18/69] style: cargo fmt chronology modules; harden split arithmetic; clear entry rects per frame --- src/activity/chronology.rs | 71 ++++++++++++++++++++++++++++---------- src/app/render.rs | 1 + src/commands/external.rs | 8 ++--- src/config/chronology.rs | 42 +++++++++++++++++----- src/data/store.rs | 19 ++++++++-- src/ui/attached.rs | 8 ++--- src/ui/chronology_bar.rs | 24 ++++++++++--- 7 files changed, 128 insertions(+), 45 deletions(-) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index 79da62c..38cc876 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -63,8 +63,8 @@ pub(crate) const SUMMARY_MAX_CHARS: usize = 80; fn looks_like_decl(line: &str) -> bool { let t = line.trim_start(); const KW: [&str; 11] = [ - "fn ", "pub ", "def ", "class ", "struct ", "impl ", "enum ", "trait ", - "func ", "type ", "const ", + "fn ", "pub ", "def ", "class ", "struct ", "impl ", "enum ", "trait ", "func ", "type ", + "const ", ]; KW.iter().any(|k| t.starts_with(k)) } @@ -161,7 +161,9 @@ pub fn extract_change_events(v: &serde_json::Value) -> Vec { tool, file_path, summary: summarize_write(content), - detail: ChangeDetail::Write { head: clip(content) }, + detail: ChangeDetail::Write { + head: clip(content), + }, }); } ChangeTool::MultiEdit => { @@ -175,7 +177,10 @@ pub fn extract_change_events(v: &serde_json::Value) -> Vec { tool, file_path: file_path.clone(), summary: summarize_edit(old, new), - detail: ChangeDetail::Edit { old: clip(old), new: clip(new) }, + detail: ChangeDetail::Edit { + old: clip(old), + new: clip(new), + }, }); } } @@ -195,7 +200,10 @@ pub fn extract_change_events(v: &serde_json::Value) -> Vec { tool, file_path, summary: summarize_edit(old, new), - detail: ChangeDetail::Edit { old: clip(old), new: clip(new) }, + detail: ChangeDetail::Edit { + old: clip(old), + new: clip(new), + }, }); } } @@ -221,29 +229,40 @@ mod extract_tests { #[test] fn extracts_edit_event() { - let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"/wt/a.rs","old_string":"let x=1;","new_string":"pub fn foo() {}"}}]}}"#); + let v = line( + r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"/wt/a.rs","old_string":"let x=1;","new_string":"pub fn foo() {}"}}]}}"#, + ); let evs = extract_change_events(&v); assert_eq!(evs.len(), 1); assert_eq!(evs[0].tool, ChangeTool::Edit); assert_eq!(evs[0].file_path, std::path::PathBuf::from("/wt/a.rs")); assert_eq!(evs[0].summary, "pub fn foo() {}"); assert!(matches!(evs[0].detail, ChangeDetail::Edit { .. })); - assert_eq!(evs[0].timestamp_ms, parse_iso8601_ms("2026-05-14T17:32:02.744Z").unwrap()); + assert_eq!( + evs[0].timestamp_ms, + parse_iso8601_ms("2026-05-14T17:32:02.744Z").unwrap() + ); } #[test] fn extracts_write_event() { - let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t2","name":"Write","input":{"file_path":"/wt/new.rs","content":"pub struct Z;"}}]}}"#); + let v = line( + r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t2","name":"Write","input":{"file_path":"/wt/new.rs","content":"pub struct Z;"}}]}}"#, + ); let evs = extract_change_events(&v); assert_eq!(evs.len(), 1); assert_eq!(evs[0].tool, ChangeTool::Write); assert_eq!(evs[0].summary, "pub struct Z;"); - assert!(matches!(&evs[0].detail, ChangeDetail::Write { head } if head.contains("struct Z"))); + assert!( + matches!(&evs[0].detail, ChangeDetail::Write { head } if head.contains("struct Z")) + ); } #[test] fn multiedit_emits_one_event_per_edit() { - let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t3","name":"MultiEdit","input":{"file_path":"/wt/a.rs","edits":[{"old_string":"a","new_string":"pub fn one(){}"},{"old_string":"b","new_string":"pub fn two(){}"}]}}]}}"#); + let v = line( + r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t3","name":"MultiEdit","input":{"file_path":"/wt/a.rs","edits":[{"old_string":"a","new_string":"pub fn one(){}"},{"old_string":"b","new_string":"pub fn two(){}"}]}}]}}"#, + ); let evs = extract_change_events(&v); assert_eq!(evs.len(), 2); assert_eq!(evs[0].tool, ChangeTool::MultiEdit); @@ -252,13 +271,17 @@ mod extract_tests { #[test] fn ignores_read_and_bash() { - let v = line(r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t4","name":"Read","input":{"file_path":"/wt/a.rs"}},{"type":"tool_use","id":"t5","name":"Bash","input":{"command":"ls"}}]}}"#); + let v = line( + r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t4","name":"Read","input":{"file_path":"/wt/a.rs"}},{"type":"tool_use","id":"t5","name":"Bash","input":{"command":"ls"}}]}}"#, + ); assert!(extract_change_events(&v).is_empty()); } #[test] fn ignores_non_assistant_lines() { - let v = line(r#"{"type":"user","timestamp":"2026-05-14T17:32:02.744Z","message":{"role":"user","content":"hi"}}"#); + let v = line( + r#"{"type":"user","timestamp":"2026-05-14T17:32:02.744Z","message":{"role":"user","content":"hi"}}"#, + ); assert!(extract_change_events(&v).is_empty()); } } @@ -296,19 +319,27 @@ mod line_tests { #[test] fn finds_line_of_old_string_first_line() { let file = "fn a() {}\nfn b() {}\nfn c() {}\n"; - let detail = ChangeDetail::Edit { old: "fn b() {}".into(), new: "fn b2() {}".into() }; + let detail = ChangeDetail::Edit { + old: "fn b() {}".into(), + new: "fn b2() {}".into(), + }; assert_eq!(resolve_line(file, &detail), 2); } #[test] fn write_resolves_to_line_one() { - let detail = ChangeDetail::Write { head: "anything".into() }; + let detail = ChangeDetail::Write { + head: "anything".into(), + }; assert_eq!(resolve_line("whatever\n", &detail), 1); } #[test] fn missing_old_string_falls_back_to_line_one() { - let detail = ChangeDetail::Edit { old: "nonexistent".into(), new: "x".into() }; + let detail = ChangeDetail::Edit { + old: "nonexistent".into(), + new: "x".into(), + }; assert_eq!(resolve_line("fn a() {}\n", &detail), 1); } @@ -340,9 +371,7 @@ pub fn parse_file(path: &Path) -> Vec { /// All `.jsonl` session files under `/.claude/projects//`. /// Testable variant taking an explicit home dir and canonical worktree path. pub(crate) fn session_files_in(home: &Path, abs_worktree: &Path) -> Vec { - let dir = home - .join(".claude/projects") - .join(encode_cwd(abs_worktree)); + let dir = home.join(".claude/projects").join(encode_cwd(abs_worktree)); let mut files = Vec::new(); let Ok(rd) = std::fs::read_dir(&dir) else { return files; @@ -554,7 +583,11 @@ mod timeline_tests { tl.refresh(&[a.clone(), b.clone()]); let evs = tl.events(); assert_eq!(evs.len(), 2); - assert_eq!(evs[0].file_path, PathBuf::from("/wt/new.rs"), "newest first"); + assert_eq!( + evs[0].file_path, + PathBuf::from("/wt/new.rs"), + "newest first" + ); assert_eq!(evs[1].file_path, PathBuf::from("/wt/old.rs")); } diff --git a/src/app/render.rs b/src/app/render.rs index f4309e0..4c1c653 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -32,6 +32,7 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { app.detail_container_rects = [None; 4]; app.attached_pane_rects.clear(); app.agent_chip_rects.clear(); + app.chronology_entry_rects.clear(); app.usage_graph_rect = None; app.usage_window_option_rects.clear(); diff --git a/src/commands/external.rs b/src/commands/external.rs index 283031e..8bc99e6 100644 --- a/src/commands/external.rs +++ b/src/commands/external.rs @@ -460,12 +460,8 @@ mod tests { #[test] fn editor_at_substitutes_file_and_line_placeholders() { - let argv = resolve_editor_at_argv( - "code --goto {file}:{line}", - "/tmp/wt/src/main.rs", - 42, - ) - .unwrap(); + let argv = + resolve_editor_at_argv("code --goto {file}:{line}", "/tmp/wt/src/main.rs", 42).unwrap(); assert_eq!(argv, vec!["code", "--goto", "/tmp/wt/src/main.rs:42"]); } diff --git a/src/config/chronology.rs b/src/config/chronology.rs index 3e64cc9..01526c3 100644 --- a/src/config/chronology.rs +++ b/src/config/chronology.rs @@ -8,17 +8,30 @@ use crate::data::store::{Repo, Store}; use serde::{Deserialize, Serialize}; -fn default_visible() -> bool { true } -fn default_percent() -> u8 { 32 } -fn default_min_cols() -> u16 { 24 } -fn default_max_cols() -> u16 { 60 } +fn default_visible() -> bool { + true +} +fn default_percent() -> u8 { + 32 +} +fn default_min_cols() -> u16 { + 24 +} +fn default_max_cols() -> u16 { + 60 +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] -pub enum Side { Left, Right } +pub enum Side { + Left, + Right, +} impl Default for Side { - fn default() -> Self { Side::Right } + fn default() -> Self { + Side::Right + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -168,7 +181,10 @@ mod tests { c.width.max_cols = 10; c.sanitize(); assert!(c.width.percent <= 80); - assert!(c.width.min_cols <= c.width.max_cols, "inverted min/max swapped"); + assert!( + c.width.min_cols <= c.width.max_cols, + "inverted min/max swapped" + ); } #[test] @@ -177,7 +193,15 @@ mod tests { c.width.percent = 50; c.width.min_cols = 20; c.width.max_cols = 30; - assert_eq!(c.resolved_width(200), 30, "50% of 200 = 100, clamped to max 30"); - assert_eq!(c.resolved_width(20), 20, "50% of 20 = 10, clamped to min 20"); + assert_eq!( + c.resolved_width(200), + 30, + "50% of 200 = 100, clamped to max 30" + ); + assert_eq!( + c.resolved_width(20), + 20, + "50% of 20 = 10, clamped to min 20" + ); } } diff --git a/src/data/store.rs b/src/data/store.rs index e91d33e..e406d04 100644 --- a/src/data/store.rs +++ b/src/data/store.rs @@ -1091,13 +1091,26 @@ mod tests { fn chronology_config_column_round_trips() { let store = Store::open_in_memory().unwrap(); let id = store.add_repo(Path::new("/tmp/r"), "r", "wsx/").unwrap(); - let repo = store.repos().unwrap().into_iter().find(|r| r.id == id).unwrap(); + let repo = store + .repos() + .unwrap() + .into_iter() + .find(|r| r.id == id) + .unwrap(); assert!(repo.chronology_config.is_none()); store .set_repo_chronology_config(id, Some(r#"{"visible":false}"#)) .unwrap(); - let repo = store.repos().unwrap().into_iter().find(|r| r.id == id).unwrap(); - assert_eq!(repo.chronology_config.as_deref(), Some(r#"{"visible":false}"#)); + let repo = store + .repos() + .unwrap() + .into_iter() + .find(|r| r.id == id) + .unwrap(); + assert_eq!( + repo.chronology_config.as_deref(), + Some(r#"{"visible":false}"#) + ); } #[test] diff --git a/src/ui/attached.rs b/src/ui/attached.rs index 4f39254..4de5c4c 100644 --- a/src/ui/attached.rs +++ b/src/ui/attached.rs @@ -68,11 +68,11 @@ pub fn split_for_chronology(area: Rect, draw: &Option>) -> (R match draw.config.side { Side::Right => { let agent = Rect { - width: area.width - bar_cols, + width: area.width.saturating_sub(bar_cols), ..area }; let bar = Rect { - x: area.x + area.width - bar_cols, + x: area.x.saturating_add(area.width).saturating_sub(bar_cols), width: bar_cols, ..area }; @@ -84,8 +84,8 @@ pub fn split_for_chronology(area: Rect, draw: &Option>) -> (R ..area }; let agent = Rect { - x: area.x + bar_cols, - width: area.width - bar_cols, + x: area.x.saturating_add(bar_cols), + width: area.width.saturating_sub(bar_cols), ..area }; (agent, Some(bar)) diff --git a/src/ui/chronology_bar.rs b/src/ui/chronology_bar.rs index dcca1db..75bae13 100644 --- a/src/ui/chronology_bar.rs +++ b/src/ui/chronology_bar.rs @@ -45,7 +45,10 @@ pub fn entry_lines( let mut out = Vec::new(); let rel = relative_display(&ev.file_path, worktree); out.push(Line::from(vec![ - Span::styled(hhmm(ev.timestamp_ms), Style::default().add_modifier(Modifier::DIM)), + Span::styled( + hhmm(ev.timestamp_ms), + Style::default().add_modifier(Modifier::DIM), + ), Span::raw(" "), Span::raw(rel), ])); @@ -93,7 +96,10 @@ mod tests { tool: ChangeTool::Edit, file_path: PathBuf::from(file), summary: summary.to_string(), - detail: ChangeDetail::Edit { old: "a".into(), new: "b".into() }, + detail: ChangeDetail::Edit { + old: "a".into(), + new: "b".into(), + }, } } @@ -117,13 +123,23 @@ mod tests { #[test] fn entry_produces_header_and_summary_lines() { - let lines = entry_lines(&ev("/wt/src/main.rs", "fn foo()"), Path::new("/wt"), false, 40); + let lines = entry_lines( + &ev("/wt/src/main.rs", "fn foo()"), + Path::new("/wt"), + false, + 40, + ); assert_eq!(lines.len(), 2, "B fidelity: header + summary, no diff peek"); } #[test] fn expanded_entry_adds_diff_peek_lines() { - let lines = entry_lines(&ev("/wt/src/main.rs", "fn foo()"), Path::new("/wt"), true, 40); + let lines = entry_lines( + &ev("/wt/src/main.rs", "fn foo()"), + Path::new("/wt"), + true, + 40, + ); assert!(lines.len() > 2, "expanded entry includes diff peek"); } } From 5d51948355397b052c64f292c96b597066f8e6af Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:43:51 -0400 Subject: [PATCH 19/69] feat(chronology): Ctrl-x c/C toggle+swap, wheel scroll, click to expand/open editor --- src/app.rs | 4 ++ src/app/input.rs | 117 +++++++++++++++++++++++++++++++++++++++++++++- src/app/render.rs | 2 + 3 files changed, 122 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index cc55954..e0a6f18 100644 --- a/src/app.rs +++ b/src/app.rs @@ -170,6 +170,9 @@ pub struct App { /// pane: `(entry_index, rect)`. Rebuilt each frame by the attached renderer /// and consumed by the input handler. Empty when the bar isn't shown. pub chronology_entry_rects: Vec<(usize, ratatui::layout::Rect)>, + /// Transient per-frame screen rect of the chronology bar (focused pane), + /// used for wheel-scroll hit-testing. `None` when the bar isn't shown. + pub chronology_bar_rect: Option, /// Per-workspace tracking for attention-alert state. pub workspace_activity: std::collections::HashMap, @@ -323,6 +326,7 @@ impl App { chronology_scroll: 0, chronology_expanded: None, chronology_entry_rects: Vec::new(), + chronology_bar_rect: None, workspace_activity: std::collections::HashMap::new(), workspace_events_scanned: std::collections::HashSet::new(), workspace_needs_attention: std::collections::HashSet::new(), diff --git a/src/app/input.rs b/src/app/input.rs index dec88b9..b1e4da5 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -243,6 +243,51 @@ fn toggle_focused_fold(app: &mut App) { app.dashboard.folded.insert(id, new_folded); } } +/// Toggle the global change-chronology bar visibility and persist it to +/// settings. Read live by the renderer via `resolve_global_only`. +fn toggle_chronology_visible(app: &mut App) { + let mut cfg = crate::config::chronology::resolve_global_only(&app.store); + cfg.visible = !cfg.visible; + if let Ok(json) = serde_json::to_string(&cfg) { + if let Err(e) = app.store.set_setting("chronology_config", &json) { + tracing::warn!(error = %e, "failed to persist chronology_config (toggle)"); + } + } +} + +/// Swap the change-chronology bar to the opposite side and persist it. +fn swap_chronology_side(app: &mut App) { + use crate::config::chronology::Side; + let mut cfg = crate::config::chronology::resolve_global_only(&app.store); + cfg.side = match cfg.side { + Side::Left => Side::Right, + Side::Right => Side::Left, + }; + if let Ok(json) = serde_json::to_string(&cfg) { + if let Err(e) = app.store.set_setting("chronology_config", &json) { + tracing::warn!(error = %e, "failed to persist chronology_config (swap side)"); + } + } +} + +/// Focused attached workspace's id and worktree path, if the current view is +/// Attached. Used by chronology mouse handling. +fn focused_attached_workspace( + app: &App, +) -> Option<(crate::data::store::WorkspaceId, std::path::PathBuf)> { + let crate::ui::View::Attached(state) = &app.view else { + return None; + }; + let target = state.focused_target()?; + let ws_id = target.workspace_id; + let worktree = app + .workspaces + .iter() + .find(|(_, w)| w.id == ws_id) + .map(|(_, w)| w.worktree_path.clone())?; + Some((ws_id, worktree)) +} + /// Vim-style `h` (fold) / `l` (unfold) on the focused row. Unlike /// [`toggle_focused_fold`], this is idempotent: pressing `h` on an /// already-folded repo leaves it folded. @@ -804,6 +849,14 @@ async fn handle_key_attached( } return Ok(()); } + KeyCode::Char('c') => { + toggle_chronology_visible(app); + return Ok(()); + } + KeyCode::Char('C') => { + swap_chronology_side(app); + return Ok(()); + } KeyCode::Char('t') => { let path = app .workspaces @@ -1676,6 +1729,34 @@ async fn handle_mouse(app: &mut App, m: MouseEvent) { } } + // Change-chronology bar: a wheel over the bar scrolls the chronology + // entries rather than the PTY/scrollback. Checked before the pane-scroll + // block below so a wheel inside the bar returns early. + if matches!( + m.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) { + if let Some(rect) = app.chronology_bar_rect { + let inside = m.column >= rect.x + && m.column < rect.x.saturating_add(rect.width) + && m.row >= rect.y + && m.row < rect.y.saturating_add(rect.height); + if inside { + let len = focused_attached_workspace(app) + .and_then(|(id, _)| app.chronology.get(&id)) + .map(|t| t.events().len()) + .unwrap_or(0); + let max_scroll = len.saturating_sub(1); + if matches!(m.kind, MouseEventKind::ScrollDown) { + app.chronology_scroll = (app.chronology_scroll + 3).min(max_scroll); + } else { + app.chronology_scroll = app.chronology_scroll.saturating_sub(3); + } + return; + } + } + } + // Attached view: a plain wheel over a pane whose agent has mouse // reporting on is forwarded to that agent's PTY so it scrolls its own // view (notably its full-screen UI, where wsx has no scrollback). @@ -1725,7 +1806,41 @@ async fn handle_mouse(app: &mut App, m: MouseEvent) { return; } - if let Some(idx) = app.chip_rects.iter().position(|r| { + if let Some(idx) = app.chronology_entry_rects.iter().find_map(|(i, r)| { + let hit = m.column >= r.x + && m.column < r.x.saturating_add(r.width) + && m.row >= r.y + && m.row < r.y.saturating_add(r.height); + hit.then_some(*i) + }) { + if app.chronology_expanded == Some(idx) { + // Second click on the already-expanded entry → open editor. + // Clone path+detail before any further `app` borrow. + let target = focused_attached_workspace(app).and_then(|(ws_id, worktree)| { + app.chronology.get(&ws_id).and_then(|t| { + t.events() + .get(idx) + .map(|ev| (worktree, ev.file_path.clone(), ev.detail.clone())) + }) + }); + if let Some((worktree, file, detail)) = target { + let line = + crate::activity::chronology::resolve_line_in_file(&file, &detail); + let editor = app.store.get_setting("editor_cmd").ok().flatten(); + if let Err(e) = crate::commands::external::open_in_editor_at( + &worktree, + &file, + line, + editor.as_deref(), + ) { + tracing::warn!(error = %e, "failed to open editor from chronology click"); + } + } + } else { + app.chronology_expanded = Some(idx); + } + return; + } else if let Some(idx) = app.chip_rects.iter().position(|r| { m.column >= r.x && m.column < r.x.saturating_add(r.width) && m.row >= r.y diff --git a/src/app/render.rs b/src/app/render.rs index 4c1c653..ee62279 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -33,6 +33,7 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { app.attached_pane_rects.clear(); app.agent_chip_rects.clear(); app.chronology_entry_rects.clear(); + app.chronology_bar_rect = None; app.usage_graph_rect = None; app.usage_window_option_rects.clear(); @@ -640,6 +641,7 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { app.attached_pane_rects = out.pane_rects; app.agent_chip_rects = out.agent_chip_rects; app.chronology_entry_rects = out.chronology_entry_rects; + app.chronology_bar_rect = bar_rect; app.pinned_commands_cache = pinned; } crate::ui::View::AttachedPm => { From 569831d5aa21427340b9e8033d2d5fca35d9d680 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:48:28 -0400 Subject: [PATCH 20/69] docs: document the change chronology view --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/README.md b/README.md index f4a836b..12fcf0e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ https://github.com/user-attachments/assets/c3ce1a7e-5904-471c-9a33-2a6fb55a16cd - [Global settings](#global-settings) - [Themes](#themes) - [Auto-rename modes](#auto-rename-modes) + - [Change chronology](#change-chronology) - [Coding agents](#coding-agents) - [Multi-agent workspaces](#multi-agent-workspaces) - [Per-repo setup scripts](#per-repo-setup-scripts) @@ -134,6 +135,8 @@ Keystrokes are forwarded to the running `claude` session, except: | `Ctrl-x v` | View diff of the attached workspace's branch vs the base branch (same `diff_cmd` as `[v]`) | | `Ctrl-x k` | Show processes running under the attached workspace's worktree | | `Ctrl-x x` | Send a literal `Ctrl-x` to claude | +| `Ctrl-x c` | Toggle the change chronology bar on/off | +| `Ctrl-x C` | Swap the chronology bar's side (left ↔ right) | When a workspace has more than one agent, the footer also binds bare keys `q w r y i o p s h j` (no leader) to switch the focused pane between agents — see [Multi-agent workspaces](#multi-agent-workspaces). @@ -552,6 +555,7 @@ Known keys: | `dashboard_name_width` | Width (chars) of the workspace-name column on the dashboard. Default `24`. Clamped to `10..=60`. | | `dashboard_branch_width` | Width (chars) of the `⎇ branch` column on the dashboard. Default `28`. Clamped to `10..=80`. | | `detail_bar_config` | JSON blob controlling the per-workspace detail bar (visibility, height, and the container/module layout). See [Workspace detail bar](#workspace-detail-bar) for the schema, defaults, and per-repo override flow. Out-of-range values are clamped on save. | +| `chronology_config` | JSON blob controlling the change chronology bar in the attached view (visibility, side, and width). See [Change chronology](#change-chronology) for the schema, defaults, and per-repo override flow. | Value sources: @@ -593,6 +597,57 @@ After your first prompt in a freshly-created workspace, wsx renames the workspac The rename only fires on workspaces whose name still matches the generated `-` pattern. +### Change chronology + +When an agent is actively editing files, it's easy to lose track of what changed, where, and when — especially across a long session with many small edits. The change chronology bar is a toggleable vertical panel docked to the side of the **attached** view that rebuilds your spatial and temporal memory of what the agent touched. + +The bar shows a newest-first, time-ordered list of individual file edits the agent made — one entry per change, not per commit. Each entry shows the time, filename, and a one-line summary of the edit. Clicking an entry expands a short diff peek inline; clicking the already-expanded entry opens your editor at the changed line. + +Currently the chronology is reconstructed from Claude Code's on-disk session logs. Support for other agents is added incrementally as those log formats are covered. + +#### Keybindings (attached view, under the `Ctrl-x` leader) + +| Key | Action | +| ---------- | ----------------------------------------------- | +| `Ctrl-x c` | Toggle the chronology bar on/off | +| `Ctrl-x C` | Swap the bar's side (left ↔ right) | + +Mouse wheel over the bar scrolls it. Click an entry to expand its diff peek; click the expanded entry again to open your editor at `file:line`. + +The click-to-open jump requires `editor_cmd` to be configured (see [Editor, terminal, and diff integration](#editor-terminal-and-diff-integration)). If your command contains `{file}` and `{line}` placeholders they are substituted in place. For recognized editors without explicit placeholders, wsx falls back to goto syntax: `code --goto file:line` for VS Code and Cursor; `+line file` for Vim/Neovim/Vi and Emacs/Emacsclient. + +#### Schema and defaults + +`chronology_config` is a JSON blob set globally via `wsx config set` or overridden per-repo via the repo settings modal (`s` on the dashboard, select the `chronology_config` row). Every field is optional; missing fields fall back to defaults. + +| Field | Type | Default | Effect | +| ---------------- | --------------------- | -------- | ---------------------------------------------------------------------- | +| `visible` | bool | `true` | Master toggle. `false` hides the bar entirely (same as `Ctrl-x c`). | +| `side` | `"left"` / `"right"` | `"right"` | Which side of the attach area the bar is docked to. | +| `width.percent` | u8 | `32` | Target width as a percent of the attach area's columns. | +| `width.min_cols` | u16 | `24` | Minimum width in columns. | +| `width.max_cols` | u16 | `60` | Maximum width in columns. | + +#### Setting the global value + +```bash +wsx config set chronology_config '{"side":"left","width":{"min_cols":30}}' +wsx config get chronology_config +wsx config set chronology_config "" # clear (reverts to defaults) +``` + +Partial JSON is fine — unspecified fields inherit defaults. Malformed JSON is rejected with a non-zero exit and the previous value is preserved. + +#### Per-repo override + +Open the repo settings modal with `s` on the dashboard, select the `chronology_config` row, and press Enter. `$EDITOR` opens on `{}\n` (or the current override). Save to apply; press `d` to clear the override and fall back to the global value. + +Example — pin the bar to the left for a repo with a wide main pane: + +```json +{ "side": "left", "width": { "percent": 28 } } +``` + ### Coding agents By default, wsx spawns Claude Code (`claude`) as the coding agent in every workspace. You can choose a different agent per-workspace or set a global default: @@ -792,6 +847,7 @@ row's repo. The modal lists the per-repo fields: - `pinned_commands` - `related_repos` - `detail_bar_config` (see [Workspace detail bar](#workspace-detail-bar)) +- `chronology_config` (see [Change chronology](#change-chronology)) `↑/↓` selects a field. Press `Enter` to edit — wsx temporarily leaves the TUI, opens `$EDITOR` (or `vi` if unset) on a tempfile prepopulated From cffb6a8ce27dee78ac4369dd0d59deb6583683f9 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 15:52:22 -0400 Subject: [PATCH 21/69] docs: record Phase 8 scope correction (other-agent extraction deferred) --- .../plans/2026-06-05-change-chronology-view.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-06-05-change-chronology-view.md b/docs/superpowers/plans/2026-06-05-change-chronology-view.md index 2ee3e1b..f7844e1 100644 --- a/docs/superpowers/plans/2026-06-05-change-chronology-view.md +++ b/docs/superpowers/plans/2026-06-05-change-chronology-view.md @@ -10,7 +10,15 @@ **Design note on decomposition:** The brainstorming spec listed "extend the existing parsers" under files touched. During planning this is refined to a **standalone extractor** in `src/activity/chronology.rs`: whole-history requires scanning *all* session files (not just the live-tailed active one), so a self-contained scanner that re-uses the existing public helpers (`encode_cwd`, `parse_iso8601_ms`) is lower-risk than threading a new event type through four live tail loops. Behavior matches the spec; only the realization differs. -**Agent sequencing:** Claude is implemented end-to-end first (richest, fully-specified JSONL). Codex/Pi/Hermes extraction are separate, clearly-bounded tasks (Phase 8) that reuse the shared `ChangeEvent` types and each parser's existing file-path extraction. Until those land, non-Claude agents simply show an empty chronology (em-dash placeholder) — no crash, no fabricated data. +**Agent sequencing:** Claude is implemented end-to-end first (richest, fully-specified JSONL). Codex/Pi/Hermes extraction are separate tasks (Phase 8). Until those land, non-Claude agents simply show an empty chronology (em-dash placeholder) — no crash, no fabricated data. + +> **Phase 8 scope correction (recorded 2026-06-05 during implementation).** This plan originally assumed Codex/Pi/Hermes parsers "already extract edited file paths" that Phase 8 could mirror. A read-only investigation of `codex_events.rs`, `pi_events.rs`, and `hermes_events.rs` found that is **false**: none of the three extract file paths or change text today. The real per-agent work is larger and format-specific, and must be done against **real captured session logs** (so TDD tests verify actual wire formats, not assumptions). Concretely: +> +> - **Codex** — logs at `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`; located by content-matching `session_meta.payload.cwd` (not a path-encoded dir). Edits arrive as a `response_item` → `payload.type == "function_call"`, `name == "apply_patch"`, with the patch carried inside the JSON-string `arguments` field. Extraction = parse `arguments` → parse the apply_patch envelope (`*** Update File: `, `@@`, `-`/`+` lines) → per-file old/new. Verdict: **PATCH (old/new derivable) — needs a real `apply_patch` sample to pin the `arguments` shape.** Timestamps are ISO8601 ms (`parse_iso8601_ms` works). +> - **Pi** — logs at `~/.pi/agent/sessions/----/_.jsonl`; `encode_cwd` (strip leading `/`, `/`→`-`, wrap `--`) + newest-mtime, mirroring `events.rs::locate_session_file`. Edits arrive as `message.content[]` blocks of type `toolCall` with an `arguments` **object**. The edit/write tool name and its arg schema (path + content?) are **not determinable from the parser/tests** — needs a real Pi edit-tool sample. Verdict: **UNKNOWN (B-ONLY or FULL) pending a sample.** Timestamps: ISO8601 ms or epoch ms (`parse_pi_timestamp`). +> - **Hermes** — **not file-based**: a SQLite DB at `~/.hermes/state.db`, `messages` table, edits inside the `tool_calls` JSON array (`function.name`/`function.arguments`), `timestamp` is REAL **seconds**. This does NOT fit the file-based `Timeline` / `claude_session_files` / `(size,mtime)` cache model — it needs a separate DB-query ingestion path (and a `Timeline` that can merge events from a non-file source). Verdict: **architecturally distinct — larger than a "mirror the parser" task.** +> +> **Implication:** Phase 8 (Tasks 17–19) is **deferred as a documented follow-up**, not completed. To do it correctly requires (a) capturing representative session logs from a real Codex/Pi/Hermes edit session, (b) generalizing `Timeline` to parse per-source (e.g. `refresh_with(files, parse_fn)` for file-based agents + a DB-source variant for Hermes), and (c) extending `App::refresh_chronology` to union all agents present for the workspace. The Claude implementation (Phases 1–7) is complete and shippable on its own; the architecture leaves clean seams for these additions. --- From 92dbe6c4ab5011fa20ffabc943f94e56e86714c1 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 16:01:09 -0400 Subject: [PATCH 22/69] feat(chronology): wire per-repo chronology_config into repo-settings modal Adds a ChronologyConfig variant to RepoSettingField (after DetailBarConfig) and wires it through every site the detail-bar config uses: the ALL array, label(), the editor-seed getter (reads repo.chronology_config), the modal row list, and apply_repo_setting. The apply arm validates the edited JSON as ChronologyOverride (the partial per-repo override type, mirroring how the detail-bar arm validates DetailBarOverride rather than the full config) and persists via store.set_repo_chronology_config, passing None when empty. Also adds a config test proving the per-repo override flows through resolve() when the global config is unset (side override -> Side::Left, other fields stay at global defaults). --- src/app.rs | 27 ++++++++++++++++++++++++++- src/config/chronology.rs | 31 +++++++++++++++++++++++++++++++ src/ui/modal.rs | 6 +++++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index e0a6f18..5cf2009 100644 --- a/src/app.rs +++ b/src/app.rs @@ -59,10 +59,11 @@ pub enum RepoSettingField { PinnedCommands, RelatedRepos, DetailBarConfig, + ChronologyConfig, } impl RepoSettingField { - pub const ALL: [Self; 9] = [ + pub const ALL: [Self; 10] = [ Self::RepoName, Self::BranchPrefix, Self::BaseBranch, @@ -72,6 +73,7 @@ impl RepoSettingField { Self::PinnedCommands, Self::RelatedRepos, Self::DetailBarConfig, + Self::ChronologyConfig, ]; pub fn label(self) -> &'static str { @@ -85,6 +87,7 @@ impl RepoSettingField { Self::PinnedCommands => "pinned_commands", Self::RelatedRepos => "related_repos", Self::DetailBarConfig => "detail_bar_config", + Self::ChronologyConfig => "chronology_config", } } } @@ -708,6 +711,13 @@ where .unwrap_or_else(|| "{}\n".to_string()); (raw, "json") } + RepoSettingField::ChronologyConfig => { + let raw = repo + .chronology_config + .clone() + .unwrap_or_else(|| "{}\n".to_string()); + (raw, "json") + } } }; @@ -958,6 +968,21 @@ pub(crate) fn apply_repo_setting( } } } + RepoSettingField::ChronologyConfig => { + if trimmed.is_empty() { + app.store.set_repo_chronology_config(repo_id, None) + } else { + // Validate. Use ChronologyOverride (not ChronologyConfig) + // because per-repo entries are partial overrides. + match serde_json::from_str::(trimmed) + { + Ok(_) => app.store.set_repo_chronology_config(repo_id, Some(trimmed)), + Err(e) => Err(crate::error::Error::UserInput(format!( + "chronology_config is not valid JSON: {e}" + ))), + } + } + } } } diff --git a/src/config/chronology.rs b/src/config/chronology.rs index 01526c3..0b29f55 100644 --- a/src/config/chronology.rs +++ b/src/config/chronology.rs @@ -149,6 +149,37 @@ pub fn resolve(repo: &Repo, store: &Store) -> ChronologyConfig { #[cfg(test)] mod tests { use super::*; + use crate::data::store::{Repo, RepoId}; + use std::path::PathBuf; + + fn test_repo(chronology_config: Option<&str>) -> Repo { + Repo { + id: RepoId(1), + name: "demo".into(), + path: PathBuf::from("/r"), + branch_prefix: String::new(), + custom_instructions: None, + setup_script: None, + archive_script: None, + pinned_commands: None, + related_repos: None, + base_branch: None, + detail_bar_config: None, + chronology_config: chronology_config.map(|s| s.to_string()), + created_at: 0, + } + } + + #[test] + fn resolve_applies_repo_override_with_global_unset() { + let store = Store::open_in_memory().unwrap(); + let repo = test_repo(Some(r#"{"side":"left"}"#)); + let cfg = resolve(&repo, &store); + assert_eq!(cfg.side, Side::Left, "repo override flips side to left"); + // Unspecified fields remain at global defaults. + assert_eq!(cfg.visible, ChronologyConfig::default().visible); + assert_eq!(cfg.width, ChronologyConfig::default().width); + } #[test] fn default_is_visible_right_sane_width() { diff --git a/src/ui/modal.rs b/src/ui/modal.rs index 7b8bae7..903de60 100644 --- a/src/ui/modal.rs +++ b/src/ui/modal.rs @@ -672,7 +672,7 @@ pub fn render_repo_settings( let body_area = chunks[0]; let footer_area = chunks[1]; - let rows: [(crate::app::RepoSettingField, Option<&str>); 9] = [ + let rows: [(crate::app::RepoSettingField, Option<&str>); 10] = [ ( crate::app::RepoSettingField::RepoName, Some(repo.name.as_str()), @@ -713,6 +713,10 @@ pub fn render_repo_settings( crate::app::RepoSettingField::DetailBarConfig, repo.detail_bar_config.as_deref(), ), + ( + crate::app::RepoSettingField::ChronologyConfig, + repo.chronology_config.as_deref(), + ), ]; let mut lines: Vec = Vec::new(); From 5b6da16f2f2fe5761af847d67123b202db85df36 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 16:01:18 -0400 Subject: [PATCH 23/69] fix(chronology): reset scroll/expanded when focused workspace changes The chronology bar's scroll offset and expanded-entry index persisted across focused-workspace switches, so they could point at an entry belonging to an unrelated workspace. Adds a chronology_last_workspace sentinel to App and a reset_chronology_state_on_workspace_change helper that zeroes the scroll and clears the expanded index when the focused workspace id changes, called in the attached renderer just before those fields are read. Mirrors the existing detail-bar reset_detail_scroll_on_workspace_change pattern. --- src/app.rs | 24 ++++++++++++++++++++++++ src/app/render.rs | 8 ++++++++ 2 files changed, 32 insertions(+) diff --git a/src/app.rs b/src/app.rs index 5cf2009..cbbb632 100644 --- a/src/app.rs +++ b/src/app.rs @@ -169,6 +169,11 @@ pub struct App { pub chronology_scroll: usize, /// Index of the currently expanded chronology entry, if any. pub chronology_expanded: Option, + /// Sentinel for the workspace the chronology scroll/expanded state belongs + /// to. When the focused attached pane switches to a different workspace, the + /// scroll offset and expanded index are reset so they can't point at an + /// unrelated entry. Mirrors `detail_scroll_last_workspace`. + pub chronology_last_workspace: Option, /// Transient per-draw hit-test rects for chronology entries in the focused /// pane: `(entry_index, rect)`. Rebuilt each frame by the attached renderer /// and consumed by the input handler. Empty when the bar isn't shown. @@ -328,6 +333,7 @@ impl App { chronology: std::collections::HashMap::new(), chronology_scroll: 0, chronology_expanded: None, + chronology_last_workspace: None, chronology_entry_rects: Vec::new(), chronology_bar_rect: None, workspace_activity: std::collections::HashMap::new(), @@ -634,6 +640,24 @@ pub(crate) fn reset_detail_scroll_on_workspace_change( } } +/// Reset the chronology bar's scroll offset and expanded entry when the focused +/// attached pane switches to a different workspace, so neither can point at an +/// entry that belongs to an unrelated workspace. Mirrors +/// `reset_detail_scroll_on_workspace_change`; takes the fields by `&mut` so the +/// borrow checker can split disjoint borrows of `App` at the call site. +pub(crate) fn reset_chronology_state_on_workspace_change( + scroll: &mut usize, + expanded: &mut Option, + last_workspace: &mut Option, + current: Option, +) { + if *last_workspace != current { + *scroll = 0; + *expanded = None; + *last_workspace = current; + } +} + /// Derive the StoppedKind for a workspace based on its WorkspaceEvents. /// Returns Some when the agent is paused waiting on the user (either /// mid-turn with a pending question tool, or end-of-turn with a diff --git a/src/app/render.rs b/src/app/render.rs index ee62279..5fe3d14 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -542,6 +542,14 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { .get(&focused_id) .map(|t| t.events().to_vec()) .unwrap_or_default(); + // If the focused pane switched to a different workspace, drop any + // stale scroll offset / expanded highlight before reading them. + crate::app::reset_chronology_state_on_workspace_change( + &mut app.chronology_scroll, + &mut app.chronology_expanded, + &mut app.chronology_last_workspace, + Some(focused_id), + ); let chronology_scroll = app.chronology_scroll; let chronology_expanded = app.chronology_expanded; From d31c8760bc6adecdbe5093f87127498317f0f4e6 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 16:01:29 -0400 Subject: [PATCH 24/69] style(chronology): clear clippy warnings (needless_return, derivable_impls) - Remove the redundant trailing `return;` at the end of the chronology-click if/else-if chain in app/input.rs: the chain is the last statement in the mouse-down arm, so the early return was a no-op (control flow unchanged). - Replace the manual `impl Default for Side` with `#[derive(Default)]` plus `#[default]` on the Right variant. Side::Right stays the default and the serde `rename_all = "lowercase"` is preserved. --- src/app/input.rs | 1 - src/config/chronology.rs | 9 ++------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/app/input.rs b/src/app/input.rs index b1e4da5..826ecf6 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -1839,7 +1839,6 @@ async fn handle_mouse(app: &mut App, m: MouseEvent) { } else { app.chronology_expanded = Some(idx); } - return; } else if let Some(idx) = app.chip_rects.iter().position(|r| { m.column >= r.x && m.column < r.x.saturating_add(r.width) diff --git a/src/config/chronology.rs b/src/config/chronology.rs index 0b29f55..c39e0fd 100644 --- a/src/config/chronology.rs +++ b/src/config/chronology.rs @@ -21,19 +21,14 @@ fn default_max_cols() -> u16 { 60 } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Side { Left, + #[default] Right, } -impl Default for Side { - fn default() -> Self { - Side::Right - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WidthSpec { #[serde(default = "default_percent")] From dd4ee6923cb6718b10eac0fe81558e78aacd2f3e Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 16:35:53 -0400 Subject: [PATCH 25/69] docs: design spec for chronology keyboard navigation --- ...5-chronology-keyboard-navigation-design.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-05-chronology-keyboard-navigation-design.md diff --git a/docs/superpowers/specs/2026-06-05-chronology-keyboard-navigation-design.md b/docs/superpowers/specs/2026-06-05-chronology-keyboard-navigation-design.md new file mode 100644 index 0000000..7506335 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-chronology-keyboard-navigation-design.md @@ -0,0 +1,154 @@ +# Chronology Keyboard Navigation — Design + +**Date:** 2026-06-05 +**Status:** Approved for planning +**Builds on:** `2026-06-05-change-chronology-view-design.md` (the Change Chronology bar) + +## Problem + +The Change Chronology bar is glanceable but only weakly interactive: today a click expands an entry's detail and a second click on the same entry opens the editor, with no keyboard path at all. Two gaps: + +1. **No keyboard navigation.** You can't move focus into the bar, walk the list, or open a change without the mouse. +2. **Opening is unclear.** The mouse "second-click-the-expanded-entry-to-open" gesture is undiscoverable; users expand an entry and have no obvious way to jump to the file. + +## Goal + +Keyboard-drive the chronology bar as a focusable pane within the attached view, and align the mouse with the same model so opening a change is obvious. + +- `Ctrl-x + arrow` moves focus **into** the bar (arrow toward the bar's side) and **out** (arrow away, or `Esc`). +- While focused, arrow keys or home-row `j`/`k` walk the list; `g`/`G` jump to top/bottom. +- `Enter` on an entry expands its detail; arrowing **into** the detail and pressing `Enter` again opens the editor at the changed line (new file → top). +- The mouse mirrors this: click an entry to select + expand it; click the expanded detail to open the file at the line. + +## Non-goals + +- Not making the chronology bar a `SplitTree` leaf (its leaves are agent PTY targets; the bar is chrome). +- Not a modal/overlay list — navigation stays in-place so the agent view remains visible beside it. +- No multi-entry expansion — one entry expanded at a time (unchanged from the base feature). + +## Decisions (from brainstorming) + +| Decision | Choice | +| --- | --- | +| Focus model | In-pane focus mode + two-level selection (`Entry`/`Detail`); not a split leaf, not a modal. | +| Enter/exit | `Ctrl-x` + arrow toward bar enters; arrow away or `Esc` exits. | +| List keys | `↑`/`k`, `↓`/`j`; `g`/`G` top/bottom. | +| Into-detail | `↓` from an expanded entry steps into its detail; `↑` from detail returns to the entry. | +| Open | `Enter` on detail (keyboard) / click on the expanded detail region (mouse) → open at `file:line`. | +| Expand | `Enter` on an entry toggles expand (keyboard) / click an entry selects + expands (mouse). | +| Mouse | Mirrors keyboard (per the chosen option). | +| `Esc` | Exits the whole pane (not `Detail`→`Entry`; `↑` does that step). | + +## Architecture (Approach 1) + +A focus mode on `App`, layered over the existing chronology state. While the bar is focused, the attached-view key handler intercepts navigation keys *before* the default "encode and forward to PTY" path; otherwise input is unchanged. The navigation logic is a **pure reducer** so it is unit-testable without a terminal; the input handler is thin glue that calls the reducer and applies side effects (open editor). + +``` +key/mouse ─▶ (if chronology_focused) reducer(sel, key, expanded, len) ─▶ (new sel, Action) + │ + Action ∈ { None, Expand(i), Collapse(i), Open(i), Exit } + ▼ + input glue applies: mutate App state / open_in_editor_at / drop focus +``` + +## State (added to `App`) + +```rust +/// Keyboard focus is currently in the chronology bar (intercept nav keys). +pub chronology_focused: bool, +/// In-pane cursor while focused. +pub chronology_sel: ChronoSel, +``` + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChronoSel { + Entry(usize), + Detail(usize), +} +impl Default for ChronoSel { fn default() -> Self { ChronoSel::Entry(0) } } +``` + +Reuses existing `chronology_expanded: Option` (expansion) and `chronology_scroll: usize` (viewport). `Detail(i)` is only valid when `chronology_expanded == Some(i)`. + +## Components + +### 1. Navigation reducer (`src/activity/chronology.rs` or a new `src/ui/chronology_nav.rs`, pure) + +```rust +pub enum NavKey { Up, Down, Top, Bottom, Enter, Esc } +pub enum NavAction { None, Expand(usize), Collapse(usize), Open(usize), Exit } + +/// Pure transition: given the current selection, a key, whether the selected +/// entry is expanded, and the entry count, return the next selection + an action +/// for the caller to apply. Bounds-safe (clamps to `len`). +pub fn nav(sel: ChronoSel, key: NavKey, expanded: Option, len: usize) -> (ChronoSel, NavAction); +``` + +Transition rules (with `len > 0`; `len == 0` → all keys except `Esc` are `(sel, None)`): +- `Down` on `Entry(i)`: + - if `expanded == Some(i)` → `(Detail(i), None)`; + - else → `(Entry(min(i+1, len-1)), None)`. +- `Down` on `Detail(i)` → `(Entry(min(i+1, len-1)), None)`. +- `Up` on `Detail(i)` → `(Entry(i), None)`. +- `Up` on `Entry(i)` → `(Entry(i.saturating_sub(1)), None)`. +- `Top` → `(Entry(0), None)`; `Bottom` → `(Entry(len-1), None)`. +- `Enter` on `Entry(i)` → if `expanded == Some(i)` then `(Entry(i), Collapse(i))` else `(Entry(i), Expand(i))`. +- `Enter` on `Detail(i)` → `(Detail(i), Open(i))`. +- `Esc` → `(sel, Exit)`. + +`Top`/`Bottom`/any move that lands on a non-expanded entry while `sel` was `Detail` collapses nothing here — collapse is only via explicit `Enter` toggle (single-expansion is enforced by `Expand` setting `chronology_expanded = Some(i)`, which the caller applies, implicitly replacing any prior expansion). + +### 2. Enter/exit glue (`src/app/input.rs`, attached leader block) + +In the `Ctrl-x` leader arrow arm (today `state.focus_direction(arrow)`): +- Resolve the configured side via `chronology::resolve(repo, store)` (the renderer already does this; the handler resolves the focused repo the same way `focused_attached_workspace` does). +- If **not** `chronology_focused` and the bar is visible/shown and `arrow` points toward the bar's side **and** the focused pane is the edge pane adjacent to the bar → set `chronology_focused = true`, `chronology_sel = Entry(0)`, return. Otherwise fall through to `state.focus_direction(arrow)` (unchanged pane navigation). +- If **already** `chronology_focused` and `arrow` points away from the bar → `chronology_focused = false`, return (focus back to agent). + +### 3. In-pane key interception (`src/app/input.rs`, in `handle_key_attached`) + +Immediately after leader handling and before the default `encode_key` → PTY forward (input.rs ~954): if `chronology_focused`, map the key to a `NavKey` (`Down`/`j`, `Up`/`k`, `g`→`Top`, `G`→`Bottom`, `Enter`, `Esc`), call `nav(...)`, store the new selection, and apply the `NavAction`: +- `Expand(i)` → `chronology_expanded = Some(i)`; `Collapse(i)` → `chronology_expanded = None`. +- `Open(i)` → resolve the focused workspace + the event at `i`, `resolve_line_in_file`, `open_in_editor_at` (reuses the existing T1/T5 functions and the click path's borrow-safe clone). +- `Exit` → `chronology_focused = false`. +- After any selection change, adjust `chronology_scroll` so the selected row stays visible. +Any key that is not a recognized nav key while focused is **swallowed** (return `Ok(())` without forwarding to the PTY). The `Ctrl-x` leader still arms normally (so exit and other chords work). + +### 4. Mouse (mirror keyboard, `src/app/input.rs` `handle_mouse`) + +- Click on an entry header rect → `chronology_focused = true`, `chronology_sel = Entry(i)`, `chronology_expanded = Some(i)`. +- Click on the **expanded detail rect** → open the editor at `file:line` for that entry (same as `Open`). +- Wheel over the bar → scroll the viewport (unchanged). + +### 5. Rendering (`src/ui/chronology_bar.rs`, `src/ui/attached.rs`, `src/app/render.rs`) + +- `ChronologyDraw` gains `focused: bool` and `sel: ChronoSel`. +- The bar header/border renders in an active style when `focused`. The selected `Entry` row is highlighted; when `sel == Detail(i)`, the detail block of entry `i` is highlighted instead. +- The renderer records the expanded entry's **detail rect** as `chronology_detail_rect: Option` on `App` (cleared each frame like the other transient rects), for mouse-open hit-testing. +- Viewport: the renderer (or the input glue) keeps the selected row within `[scroll, scroll+visible_rows)`. + +### 6. Lifecycle / edge cases + +- The existing focused-workspace-change reset also resets `chronology_focused = false` and `chronology_sel = Entry(0)`. +- Selection is clamped to the event count every resolve; an empty timeline makes entering a no-op (nothing to select). +- If the bar auto-hides (terminal narrowed) or is toggled off while focused, `chronology_focused` drops to `false` so keys flow back to the agent. +- `chronology_detail_rect` and the entry rects are cleared at the top of each frame (the existing per-frame clear block). + +## Testing + +- **Reducer (`nav`)** — table-driven over every transition: `Down`/`Up` across `Entry`↔`Detail`, into-detail only when expanded, `Top`/`Bottom`, `Enter` expand/collapse/open, `Esc` exit, and `len == 0` / single-entry / boundary clamps. +- **Auto-scroll math** — selecting above/below the viewport adjusts `chronology_scroll` to keep the row visible; pure function tested directly. +- **Mouse hit-resolution** — header rect vs detail rect → select+expand vs open; reuses the existing rect-hit pattern. +- **Key mapping** — `j`/`k`/`g`/`G`/arrows/Enter/Esc → `NavKey`, and that non-nav keys are swallowed while focused (logic-level test where feasible). +- Existing chronology tests remain green; no regression to PTY forwarding when not focused. + +## Files touched + +- `src/ui/chronology_nav.rs` (new) — `ChronoSel`, `NavKey`, `NavAction`, `nav`, auto-scroll helper (pure). +- `src/app.rs` — `chronology_focused`, `chronology_sel`, `chronology_detail_rect` + init + reset hook. +- `src/app/input.rs` — enter/exit via `Ctrl-x`+arrow; in-pane key interception; mouse header/detail handling. +- `src/ui/chronology_bar.rs` — focus/selection highlight; detail-rect recording; `ChronologyDraw` fields. +- `src/ui/attached.rs` — thread focus/selection + detail rect through `render_panes`. +- `src/app/render.rs` — pass focus/selection into the draw; clear `chronology_detail_rect`; keep selection in view. +- `README.md` — document the keyboard navigation and the mouse model. From 8525933ec2921a33313567bbf57b27d754af0f41 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 16:43:02 -0400 Subject: [PATCH 26/69] docs: implementation plan for chronology keyboard navigation --- ...26-06-05-chronology-keyboard-navigation.md | 874 ++++++++++++++++++ 1 file changed, 874 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-chronology-keyboard-navigation.md diff --git a/docs/superpowers/plans/2026-06-05-chronology-keyboard-navigation.md b/docs/superpowers/plans/2026-06-05-chronology-keyboard-navigation.md new file mode 100644 index 0000000..bcab60c --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-chronology-keyboard-navigation.md @@ -0,0 +1,874 @@ +# Chronology Keyboard Navigation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Change Chronology bar a focusable, keyboard-navigable pane in the attached view — `Ctrl-x`+arrow to enter/exit, arrows/`j`/`k`/`g`/`G` to walk the list, `Enter` to expand a detail then `Enter` again (after arrowing into it) to open the editor at the changed line — and align the mouse to the same model. + +**Architecture:** A focus mode on `App` (`chronology_focused` + a two-level `ChronoSel` cursor) layered over the existing chronology state. The navigation logic is a **pure reducer** (`nav`) so it's unit-tested without a terminal; the input handler is thin glue that calls the reducer and applies side effects. While focused, the attached key handler intercepts nav keys before the PTY-forward path. Mouse mirrors keyboard. + +**Tech Stack:** Rust, `ratatui` (TUI), `crossterm` (input). Tests are `#[cfg(test)]` unit tests via `cargo test`. + +**Builds on:** the shipped Change Chronology bar. Current facts (verified): +- `src/ui/attached.rs`: `pub struct ChronologyDraw<'a> { config, events, worktree, scroll, expanded }` (lines ~30-35); `render_panes(..., chronology_bar: Option<(Rect, ChronologyDraw<'_>)>, theme)`; private `render_chronology_bar(f, bar_rect, draw, theme) -> Vec<(usize, Rect)>` (returns header-line rects, skips by `draw.scroll`); `PanesDrawOutput { ..., chronology_entry_rects: Vec<(usize, Rect)> }`; `pub fn split_for_chronology(area, &Option) -> (Rect, Option)`. +- `src/ui/chronology_bar.rs`: `pub fn entry_lines(ev: &ChangeEvent, worktree: &Path, expanded: bool, width: u16) -> Vec>`; `pub const MIN_AGENT_COLS`; `should_auto_hide`; `relative_display`. +- `src/app.rs`: `App` has `chronology: HashMap`, `chronology_scroll: usize`, `chronology_expanded: Option`, `chronology_entry_rects: Vec<(usize, Rect)>`, `chronology_bar_rect: Option`, `chronology_last_workspace: Option`; `pub fn refresh_chronology(...)`; `fn reset_chronology_state_on_workspace_change(...)` (resets scroll/expanded on workspace change). +- `src/app/input.rs`: attached leader block (`if app.leader_pending { match k.code { ... KeyCode::Left|Right|Up|Down => state.focus_direction(arrow), ... } }`); `state.focus_direction(arrow: Arrow) -> bool` returns whether focus moved; leader re-arm at `if k.code == LEADER_KEY && ctrl { app.leader_pending = true; return }`; default `let bytes = encode_key(k); session.writer.send(bytes)`; `handle_mouse` with a `Down(Left)` rect-hit chain whose FIRST branch hits `chronology_entry_rects`; `fn focused_attached_workspace(app) -> Option<(WorkspaceId, PathBuf)>`; a wheel-over-`chronology_bar_rect` scroll block. +- `crate::config::chronology::{resolve, Side}`; `crate::activity::chronology::{ChangeEvent, ChangeDetail, resolve_line_in_file}`; `crate::commands::external::open_in_editor_at`. + +--- + +## File Structure + +- `src/ui/chronology_nav.rs` (create) — pure nav: `ChronoSel`, `NavKey`, `NavAction`, `nav`, `adjust_scroll`. The single source of truth for the cursor state machine. +- `src/ui/mod.rs` (modify) — `pub mod chronology_nav;`. +- `src/ui/chronology_bar.rs` (modify) — `EntryHighlight` + add a highlight arg to `entry_lines` so the selected header / detail block renders highlighted. +- `src/ui/attached.rs` (modify) — `ChronologyDraw` gains `focused` + `sel`; `render_chronology_bar` highlights and returns a `ChronologyHits { entries, detail, visible_entries }`; thread through `render_panes`/`PanesDrawOutput`. +- `src/app.rs` (modify) — `chronology_focused`, `chronology_sel`, `chronology_detail_rect`, `chronology_visible_entries` + init + reset hook. +- `src/app/render.rs` (modify) — pass `focused`/`sel` into the draw; store detail rect + visible count; apply `adjust_scroll`; clear the new transient rect each frame. +- `src/app/input.rs` (modify) — `Ctrl-x`+arrow enter/exit; in-pane nav-key interception; mouse header=select+expand / detail=open. +- `README.md` (modify) — document keyboard nav + mouse model. + +--- + +## Task 1: Pure navigation reducer + +**Files:** +- Create: `src/ui/chronology_nav.rs` +- Modify: `src/ui/mod.rs` + +- [ ] **Step 1: Create the module with types + tests** + +Create `src/ui/chronology_nav.rs`: + +```rust +//! Pure cursor state machine for keyboard navigation of the chronology bar. +//! Kept free of `App`/`ratatui` so every transition is unit-testable. +//! +//! See `docs/superpowers/specs/2026-06-05-chronology-keyboard-navigation-design.md`. + +/// In-pane cursor while the chronology bar is keyboard-focused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChronoSel { + Entry(usize), + Detail(usize), +} + +impl Default for ChronoSel { + fn default() -> Self { + ChronoSel::Entry(0) + } +} + +impl ChronoSel { + /// The entry index this cursor refers to (entry or its detail). + pub fn index(self) -> usize { + match self { + ChronoSel::Entry(i) | ChronoSel::Detail(i) => i, + } + } +} + +/// A navigation key, already mapped from the raw keystroke. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavKey { + Up, + Down, + Top, + Bottom, + Enter, + Esc, +} + +/// Side effect the caller must apply after a transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavAction { + None, + Expand(usize), + Collapse(usize), + Open(usize), + Exit, +} + +/// Pure transition. `expanded` is the currently-expanded entry index (the bar +/// allows one at a time); `len` is the entry count. Bounds-safe: never returns +/// an index >= `len` (when `len > 0`). +pub fn nav(sel: ChronoSel, key: NavKey, expanded: Option, len: usize) -> (ChronoSel, NavAction) { + if key == NavKey::Esc { + return (sel, NavAction::Exit); + } + if len == 0 { + return (sel, NavAction::None); + } + let last = len - 1; + match (sel, key) { + // Down + (ChronoSel::Entry(i), NavKey::Down) => { + if expanded == Some(i) { + (ChronoSel::Detail(i), NavAction::None) + } else { + (ChronoSel::Entry((i + 1).min(last)), NavAction::None) + } + } + (ChronoSel::Detail(i), NavKey::Down) => (ChronoSel::Entry((i + 1).min(last)), NavAction::None), + // Up + (ChronoSel::Detail(i), NavKey::Up) => (ChronoSel::Entry(i), NavAction::None), + (ChronoSel::Entry(i), NavKey::Up) => (ChronoSel::Entry(i.saturating_sub(1)), NavAction::None), + // Top / Bottom + (_, NavKey::Top) => (ChronoSel::Entry(0), NavAction::None), + (_, NavKey::Bottom) => (ChronoSel::Entry(last), NavAction::None), + // Enter + (ChronoSel::Entry(i), NavKey::Enter) => { + if expanded == Some(i) { + (ChronoSel::Entry(i), NavAction::Collapse(i)) + } else { + (ChronoSel::Entry(i), NavAction::Expand(i)) + } + } + (ChronoSel::Detail(i), NavKey::Enter) => (ChronoSel::Detail(i), NavAction::Open(i)), + // Esc handled above. + (_, NavKey::Esc) => unreachable!(), + } +} + +/// Adjust the viewport `scroll` so the selected entry index stays visible, +/// given how many entries were visible last frame. One-frame lag is fine. +pub fn adjust_scroll(scroll: usize, sel_index: usize, visible: usize, len: usize) -> usize { + if len == 0 { + return 0; + } + if sel_index < scroll { + return sel_index; + } + if visible > 0 && sel_index >= scroll + visible { + return sel_index + 1 - visible; + } + scroll +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn down_moves_to_next_entry_when_collapsed() { + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Down, None, 3), (ChronoSel::Entry(1), NavAction::None)); + } + + #[test] + fn down_steps_into_detail_when_expanded() { + assert_eq!(nav(ChronoSel::Entry(1), NavKey::Down, Some(1), 3), (ChronoSel::Detail(1), NavAction::None)); + } + + #[test] + fn down_from_detail_goes_to_next_entry() { + assert_eq!(nav(ChronoSel::Detail(1), NavKey::Down, Some(1), 3), (ChronoSel::Entry(2), NavAction::None)); + } + + #[test] + fn up_from_detail_returns_to_entry() { + assert_eq!(nav(ChronoSel::Detail(2), NavKey::Up, Some(2), 3), (ChronoSel::Entry(2), NavAction::None)); + } + + #[test] + fn up_from_entry_goes_previous_saturating() { + assert_eq!(nav(ChronoSel::Entry(1), NavKey::Up, None, 3), (ChronoSel::Entry(0), NavAction::None)); + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Up, None, 3), (ChronoSel::Entry(0), NavAction::None)); + } + + #[test] + fn down_clamps_at_last() { + assert_eq!(nav(ChronoSel::Entry(2), NavKey::Down, None, 3), (ChronoSel::Entry(2), NavAction::None)); + } + + #[test] + fn top_and_bottom() { + assert_eq!(nav(ChronoSel::Detail(1), NavKey::Top, Some(1), 3), (ChronoSel::Entry(0), NavAction::None)); + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Bottom, None, 3), (ChronoSel::Entry(2), NavAction::None)); + } + + #[test] + fn enter_toggles_expand_on_entry() { + assert_eq!(nav(ChronoSel::Entry(1), NavKey::Enter, None, 3), (ChronoSel::Entry(1), NavAction::Expand(1))); + assert_eq!(nav(ChronoSel::Entry(1), NavKey::Enter, Some(1), 3), (ChronoSel::Entry(1), NavAction::Collapse(1))); + } + + #[test] + fn enter_on_detail_opens() { + assert_eq!(nav(ChronoSel::Detail(1), NavKey::Enter, Some(1), 3), (ChronoSel::Detail(1), NavAction::Open(1))); + } + + #[test] + fn esc_exits_from_anywhere() { + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Esc, None, 3).1, NavAction::Exit); + assert_eq!(nav(ChronoSel::Detail(2), NavKey::Esc, Some(2), 3).1, NavAction::Exit); + } + + #[test] + fn empty_list_only_exits() { + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Down, None, 0), (ChronoSel::Entry(0), NavAction::None)); + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Enter, None, 0), (ChronoSel::Entry(0), NavAction::None)); + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Esc, None, 0).1, NavAction::Exit); + } + + #[test] + fn adjust_scroll_keeps_selection_visible() { + // selection above viewport → scroll up to it + assert_eq!(adjust_scroll(5, 2, 4, 10), 2); + // selection below viewport → scroll so it's the last visible + assert_eq!(adjust_scroll(0, 6, 4, 10), 3); + // selection already visible → unchanged + assert_eq!(adjust_scroll(2, 3, 4, 10), 2); + // empty + assert_eq!(adjust_scroll(3, 0, 4, 0), 0); + } + + #[test] + fn index_extracts_entry_index() { + assert_eq!(ChronoSel::Entry(4).index(), 4); + assert_eq!(ChronoSel::Detail(7).index(), 7); + } +} +``` + +- [ ] **Step 2: Wire the module** + +In `src/ui/mod.rs`, add `pub mod chronology_nav;` next to the other `pub mod` lines. + +- [ ] **Step 3: Run tests to verify they fail then pass** + +Run: `cargo test --lib chronology_nav` +Expected: after creating the file, all tests PASS (the module is self-contained, so there's no separate red phase beyond the initial compile). If anything fails, fix the reducer — the tests are the spec. + +- [ ] **Step 4: Verify build** + +Run: `cargo build` — clean, zero warnings (the module is referenced by tests; `pub` items won't warn). + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/chronology_nav.rs src/ui/mod.rs +git commit -m "feat(chronology): pure keyboard-nav reducer (ChronoSel/nav/adjust_scroll)" +``` + +--- + +## Task 2: Highlight the selected entry / detail in `entry_lines` + +**Files:** +- Modify: `src/ui/chronology_bar.rs` + +- [ ] **Step 1: Write/extend the failing tests** + +In `src/ui/chronology_bar.rs` tests, add (and update existing `entry_lines` call sites to pass the new arg — see Step 3): + +```rust +#[test] +fn header_highlight_reverses_first_line() { + let lines = entry_lines(&ev("/wt/a.rs", "fn foo()"), Path::new("/wt"), true, 40, EntryHighlight::Header); + // first line (header) carries REVERSED + let has_rev = lines[0].spans.iter().any(|s| s.style.add_modifier.contains(ratatui::style::Modifier::REVERSED)); + assert!(has_rev, "header line should be highlighted"); +} + +#[test] +fn detail_highlight_reverses_peek_lines_only() { + let lines = entry_lines(&ev("/wt/a.rs", "fn foo()"), Path::new("/wt"), true, 40, EntryHighlight::Detail); + // header NOT reversed + assert!(!lines[0].spans.iter().any(|s| s.style.add_modifier.contains(ratatui::style::Modifier::REVERSED))); + // at least one peek line (index >= 2) reversed + let peek_rev = lines.iter().skip(2).any(|l| l.spans.iter().any(|s| s.style.add_modifier.contains(ratatui::style::Modifier::REVERSED))); + assert!(peek_rev, "detail peek should be highlighted"); +} + +#[test] +fn no_highlight_leaves_lines_unreversed() { + let lines = entry_lines(&ev("/wt/a.rs", "fn foo()"), Path::new("/wt"), false, 40, EntryHighlight::None); + assert!(!lines.iter().any(|l| l.spans.iter().any(|s| s.style.add_modifier.contains(ratatui::style::Modifier::REVERSED)))); +} +``` + +(The existing `ev(...)` test helper already exists in this module.) + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib chronology_bar` +Expected: FAIL — `EntryHighlight` not found / arity mismatch. + +- [ ] **Step 3: Implement the highlight** + +Add the enum and extend `entry_lines` in `src/ui/chronology_bar.rs`. Add `use ratatui::style::Modifier;` if not already imported (it is). Define: + +```rust +/// Which part of an entry is keyboard-selected (for highlight). `None` when the +/// entry isn't the cursor target. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryHighlight { + None, + Header, + Detail, +} +``` + +Change the signature to: + +```rust +pub fn entry_lines( + ev: &ChangeEvent, + worktree: &Path, + expanded: bool, + width: u16, + highlight: EntryHighlight, +) -> Vec> { +``` + +Build the lines as today (header line, then summary, then — when `expanded` — the diff-peek lines). Then, just before returning, apply the highlight by adding `Modifier::REVERSED` to the spans of the relevant lines: + +```rust + let mut out = out; // existing accumulator + match highlight { + EntryHighlight::None => {} + EntryHighlight::Header => { + if let Some(first) = out.first_mut() { + for s in &mut first.spans { + s.style = s.style.add_modifier(Modifier::REVERSED); + } + } + } + EntryHighlight::Detail => { + // peek lines are everything after the header(0) and summary(1) + for line in out.iter_mut().skip(2) { + for s in &mut line.spans { + s.style = s.style.add_modifier(Modifier::REVERSED); + } + } + } + } + out +``` + +(Adapt to the actual variable name the function uses for its `Vec` accumulator — read the current body first.) + +- [ ] **Step 4: Update existing callers in this file's tests** + +The current tests call `entry_lines(ev, wt, expanded, width)` with 4 args. Update each to pass `EntryHighlight::None` as the 5th arg so they compile. (The non-test caller in `attached.rs` is updated in Task 3.) + +- [ ] **Step 5: Run to verify pass** + +Run: `cargo test --lib chronology_bar` +Expected: PASS (existing + 3 new). NOTE: this leaves `attached.rs` calling `entry_lines` with the old arity — the crate will not fully build until Task 3. That's expected; this task's unit tests run via `cargo test --lib chronology_bar` which compiles the lib — so if the lib doesn't compile due to attached.rs, do Task 3's `entry_lines` call update in the same commit. To keep the tree compiling, **fold Step 3 of Task 3 (the single `entry_lines` call-site update in attached.rs) into this commit.** Concretely: in `src/ui/attached.rs` `render_chronology_bar`, change the `entry_lines(ev, draw.worktree, expanded, inner_width)` call to `entry_lines(ev, draw.worktree, expanded, inner_width, EntryHighlight::None)` for now (real highlight wiring lands in Task 3). + +- [ ] **Step 6: Commit** + +```bash +git add src/ui/chronology_bar.rs src/ui/attached.rs +git commit -m "feat(chronology): EntryHighlight arg on entry_lines for selection rendering" +``` + +--- + +## Task 3: `ChronologyDraw` focus/sel + detail rect + visible count + +**Files:** +- Modify: `src/ui/attached.rs` + +- [ ] **Step 1: Extend `ChronologyDraw` and the hits return** + +In `src/ui/attached.rs`: + +Add fields to `ChronologyDraw`: + +```rust + /// Keyboard focus is in the bar (drives the active header + selection highlight). + pub focused: bool, + /// In-pane cursor while focused. + pub sel: crate::ui::chronology_nav::ChronoSel, +``` + +Add a hits struct near `PaneSpec`: + +```rust +/// Mouse/scroll hit targets produced by painting the chronology bar. +pub struct ChronologyHits { + /// `(entry_index, header_rect)` per drawn entry. + pub entries: Vec<(usize, Rect)>, + /// The expanded entry's detail block `(entry_index, rect)`, if any was drawn. + pub detail: Option<(usize, Rect)>, + /// Number of entries drawn this frame (for auto-scroll). + pub visible_entries: usize, +} +``` + +Add to `PanesDrawOutput`: + +```rust + /// The expanded entry's detail rect (for mouse "open at line"), if shown. + pub chronology_detail_rect: Option<(usize, Rect)>, + /// Entries drawn in the chronology bar this frame (for keyboard auto-scroll). + pub chronology_visible_entries: usize, +``` + +Initialize both at the `PanesDrawOutput` construction site (default `None` / `0` when no bar). + +- [ ] **Step 2: Update `render_chronology_bar` to highlight + return `ChronologyHits`** + +Change its return type to `ChronologyHits` and, inside the entry loop, compute the highlight from `draw.focused`/`draw.sel` and capture the detail rect: + +```rust +fn render_chronology_bar( + f: &mut Frame, + bar_rect: Rect, + draw: &ChronologyDraw<'_>, + theme: &Theme, +) -> ChronologyHits { + use crate::ui::chronology_bar::EntryHighlight; + use crate::ui::chronology_nav::ChronoSel; + // ... unchanged early returns now return ChronologyHits::default-equivalent: + // ChronologyHits { entries: Vec::new(), detail: None, visible_entries: 0 } + // ... header rendering: when draw.focused, render the header with an active + // style, e.g. theme.header_style().add_modifier(Modifier::BOLD); else as today. + + let mut entry_rects: Vec<(usize, Rect)> = Vec::new(); + let mut detail_rect: Option<(usize, Rect)> = None; + let mut visible_entries = 0usize; + + let mut cursor_y = body_y; + for (i, ev) in draw.events.iter().enumerate().skip(draw.scroll) { + if cursor_y >= body_bottom { + break; + } + let expanded = Some(i) == draw.expanded; + let highlight = if draw.focused { + match draw.sel { + ChronoSel::Entry(s) if s == i => EntryHighlight::Header, + ChronoSel::Detail(s) if s == i => EntryHighlight::Detail, + _ => EntryHighlight::None, + } + } else { + EntryHighlight::None + }; + let lines = crate::ui::chronology_bar::entry_lines(ev, draw.worktree, expanded, inner_width, highlight); + let available = body_bottom.saturating_sub(cursor_y); + let drawn = (lines.len() as u16).min(available); + if drawn == 0 { + break; + } + let entry_area = Rect { x: content.x, y: cursor_y, width: inner_width, height: drawn }; + f.render_widget(Paragraph::new(lines), entry_area); + entry_rects.push((i, Rect { x: content.x, y: cursor_y, width: inner_width, height: 1 })); + // The detail block is the rows below the header line (when expanded & drawn). + if expanded && drawn > 1 { + detail_rect = Some(( + i, + Rect { x: content.x, y: cursor_y.saturating_add(1), width: inner_width, height: drawn - 1 }, + )); + } + visible_entries += 1; + cursor_y = cursor_y.saturating_add(drawn); + } + + ChronologyHits { entries: entry_rects, detail: detail_rect, visible_entries } +} +``` + +Adapt the early-return sites (`bar_rect.width==0`, empty content, empty events) to return a `ChronologyHits` with empty `entries`, `None` detail, `0` visible. + +- [ ] **Step 3: Thread the hits through `render_panes`** + +In `render_panes`, the call site currently does: + +```rust + let chronology_entry_rects = match chronology_bar { + Some((bar_rect, draw)) => render_chronology_bar(f, bar_rect, &draw, theme), + None => Vec::new(), + }; +``` + +Change to: + +```rust + let chronology_hits = match chronology_bar { + Some((bar_rect, draw)) => render_chronology_bar(f, bar_rect, &draw, theme), + None => ChronologyHits { entries: Vec::new(), detail: None, visible_entries: 0 }, + }; +``` + +and in the returned `PanesDrawOutput`, set: + +```rust + chronology_entry_rects: chronology_hits.entries, + chronology_detail_rect: chronology_hits.detail, + chronology_visible_entries: chronology_hits.visible_entries, +``` + +- [ ] **Step 4: Build** + +Run: `cargo build` and `cargo test --lib attached` +Expected: clean build, zero warnings; existing attached tests still pass. (Callers in `render.rs` that construct `ChronologyDraw` must now set `focused`/`sel` — update them in Task 5; to keep the tree compiling, **do Task 5's `ChronologyDraw { ... }` field additions in this commit** by setting `focused: false, sel: Default::default()` at the render.rs construction site, with the real values wired in Task 5.) + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/attached.rs src/app/render.rs +git commit -m "feat(chronology): focus/selection + detail-rect/visible-count in bar rendering" +``` + +--- + +## Task 4: App state for focus + selection + +**Files:** +- Modify: `src/app.rs` + +- [ ] **Step 1: Add fields** + +Add to the `App` struct (next to the other `chronology_*` fields): + +```rust + /// Keyboard focus is in the chronology bar (intercept nav keys). + pub chronology_focused: bool, + /// In-pane cursor while focused. + pub chronology_sel: crate::ui::chronology_nav::ChronoSel, + /// Transient per-frame detail rect of the expanded entry `(index, rect)`, + /// for mouse "open at line". `None` when nothing is expanded/shown. + pub chronology_detail_rect: Option<(usize, ratatui::layout::Rect)>, + /// Entries drawn in the bar last frame (for keyboard auto-scroll). + pub chronology_visible_entries: usize, +``` + +Initialize in `App::new`'s `Self { ... }` literal: + +```rust + chronology_focused: false, + chronology_sel: crate::ui::chronology_nav::ChronoSel::default(), + chronology_detail_rect: None, + chronology_visible_entries: 0, +``` + +- [ ] **Step 2: Extend the workspace-change reset** + +Find `reset_chronology_state_on_workspace_change` (it resets `chronology_scroll`/`chronology_expanded` on focused-workspace change). Extend it to also reset focus + selection: + +```rust + // inside the "changed" branch, alongside scroll = 0 / expanded = None: + *chronology_focused = false; + *chronology_sel = crate::ui::chronology_nav::ChronoSel::Entry(0); +``` + +(Match the function's existing `&mut`-field parameter style; add `chronology_focused: &mut bool` and `chronology_sel: &mut ChronoSel` params and pass them at the call site in `render.rs`, OR if it takes `&mut self`-style access, set the fields directly. Read the current signature and mirror it.) + +- [ ] **Step 3: Build** + +Run: `cargo build` +Expected: clean (these are `pub` fields/used in render next task; no dead-code warnings for `pub`). + +- [ ] **Step 4: Commit** + +```bash +git add src/app.rs +git commit -m "feat(chronology): app state for keyboard focus + selection" +``` + +--- + +## Task 5: Render wiring — pass focus/sel, store hits, auto-scroll + +**Files:** +- Modify: `src/app/render.rs` + +- [ ] **Step 1: Clear the new transient rect each frame** + +In the per-frame clear block at the top of `draw()` (where `app.chronology_bar_rect = None;` etc. are set), add: + +```rust + app.chronology_detail_rect = None; +``` + +(`chronology_visible_entries` is overwritten each Attached frame, so it doesn't strictly need clearing, but you may set it to 0 here for cleanliness.) + +- [ ] **Step 2: Build `ChronologyDraw` with focus + selection and auto-scroll before drawing** + +In the `View::Attached` arm, where `ChronologyDraw { ... }` is constructed (currently with `scroll: app.chronology_scroll, expanded: app.chronology_expanded`), first apply auto-scroll so the selected row is visible, then pass focus + sel: + +```rust + // Keep the keyboard selection in view (uses last frame's visible count). + if app.chronology_focused { + app.chronology_scroll = crate::ui::chronology_nav::adjust_scroll( + app.chronology_scroll, + app.chronology_sel.index(), + app.chronology_visible_entries, + chronology_events.len(), + ); + } + // ... existing chronology_events / worktree / cfg locals ... + let chronology_draw = bar_rect.map(|_| crate::ui::attached::ChronologyDraw { + config: &chronology_cfg, + events: &chronology_events, + worktree: &chronology_worktree, + scroll: app.chronology_scroll, + expanded: app.chronology_expanded, + focused: app.chronology_focused, + sel: app.chronology_sel, + }); +``` + +(Match the ACTUAL local variable names already in this arm — `chronology_events`, `chronology_worktree`, `chronology_cfg`, `bar_rect`, and however `chronology_draw` is currently built/zipped. The only changes are: the `adjust_scroll` call before construction, and the two new struct fields.) + +- [ ] **Step 3: Store the new hits after `render_panes` returns** + +Where `app.chronology_entry_rects = out.chronology_entry_rects;` is set, add: + +```rust + app.chronology_detail_rect = out.chronology_detail_rect; + app.chronology_visible_entries = out.chronology_visible_entries; +``` + +- [ ] **Step 4: Build + manual sanity** + +Run: `cargo build` (zero warnings) and `cargo test --lib` (no regressions). +Manual: attach to a Claude workspace; the bar still renders; nothing is focusable yet (input lands in Task 6), but the default (`focused:false`) path must look exactly as before. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/render.rs +git commit -m "feat(chronology): wire focus/selection + auto-scroll into the attached render" +``` + +--- + +## Task 6: Input — enter/exit + in-pane key navigation + +**Files:** +- Modify: `src/app/input.rs` + +- [ ] **Step 1: Enter/exit via `Ctrl-x` + arrow** + +In `handle_key_attached`'s leader block, locate the arrow arm that calls `state.focus_direction(arrow)`. Replace it so the chronology bar participates. Read the focused repo's config to know the side. Add a small helper near `focused_attached_workspace`: + +```rust +/// Resolve the configured chronology side for the focused attached workspace. +fn focused_chronology_side(app: &App) -> Option { + let crate::ui::View::Attached(state) = &app.view else { return None }; + let target = state.focused_target()?; + let ws_id = target.workspace_id; + let (rid, _w) = app.workspaces.iter().find(|(_, w)| w.id == ws_id)?; + let repo = app.repos.iter().find(|r| r.id == *rid)?; + Some(crate::config::chronology::resolve(repo, &app.store).side) +} +``` + +Then the arrow arm becomes (adapt to the actual `Arrow` mapping already present): + +```rust + KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down => { + let arrow = /* existing match k.code -> Arrow */; + use crate::config::chronology::Side; + let side = focused_chronology_side(app); + let toward_bar = matches!( + (side, arrow), + (Some(Side::Right), Arrow::Right) | (Some(Side::Left), Arrow::Left) + ); + let away_from_bar = matches!( + (side, arrow), + (Some(Side::Right), Arrow::Left) | (Some(Side::Left), Arrow::Right) + ); + if app.chronology_focused { + if away_from_bar { + app.chronology_focused = false; // back to the agent pane + } + // toward/parallel arrows while focused: ignored here + return Ok(()); + } + if toward_bar && app.chronology_bar_rect.is_some() { + // Only enter the bar if there's no agent pane further in that + // direction (we're at the edge). focus_direction returns false + // when it couldn't move. + let moved = if let View::Attached(state) = &mut app.view { + state.focus_direction(arrow) + } else { + false + }; + if !moved { + app.chronology_focused = true; + app.chronology_sel = crate::ui::chronology_nav::ChronoSel::Entry(0); + app.chronology_scroll = 0; + } + return Ok(()); + } + if let View::Attached(state) = &mut app.view { + state.focus_direction(arrow); + } + return Ok(()); + } +``` + +(`app.chronology_bar_rect.is_some()` is the cheap "bar is shown" check — it's set each frame by the renderer. `Arrow` is `crate::ui::split::Arrow`.) + +- [ ] **Step 2: In-pane key interception** + +After the leader-arming block (`if k.code == LEADER_KEY && ctrl { app.leader_pending = true; return Ok(()) }`) and BEFORE the default `let bytes = encode_key(k); session.writer.send(bytes)`, insert: + +```rust + if app.chronology_focused { + use crate::ui::chronology_nav::{nav, NavAction, NavKey}; + let navkey = match k.code { + KeyCode::Down | KeyCode::Char('j') => Some(NavKey::Down), + KeyCode::Up | KeyCode::Char('k') => Some(NavKey::Up), + KeyCode::Char('g') => Some(NavKey::Top), + KeyCode::Char('G') => Some(NavKey::Bottom), + KeyCode::Enter => Some(NavKey::Enter), + KeyCode::Esc => Some(NavKey::Esc), + _ => None, + }; + if let Some(navkey) = navkey { + let len = focused_attached_workspace(app) + .and_then(|(id, _)| app.chronology.get(&id)) + .map(|t| t.events().len()) + .unwrap_or(0); + let (new_sel, action) = nav(app.chronology_sel, navkey, app.chronology_expanded, len); + app.chronology_sel = new_sel; + match action { + NavAction::None => {} + NavAction::Expand(i) => app.chronology_expanded = Some(i), + NavAction::Collapse(_) => app.chronology_expanded = None, + NavAction::Exit => app.chronology_focused = false, + NavAction::Open(i) => { + if let Some((worktree, file, detail)) = focused_attached_workspace(app) + .and_then(|(ws_id, worktree)| { + app.chronology.get(&ws_id).and_then(|t| { + t.events().get(i).map(|ev| (worktree, ev.file_path.clone(), ev.detail.clone())) + }) + }) + { + let line = crate::activity::chronology::resolve_line_in_file(&file, &detail); + let editor = app.store.get_setting("editor_cmd").ok().flatten(); + if let Err(e) = crate::commands::external::open_in_editor_at(&worktree, &file, line, editor.as_deref()) { + tracing::warn!(error = %e, "failed to open editor from chronology keyboard"); + } + } + } + } + } + // While focused, swallow ALL keys (recognized or not) — the agent PTY + // must not receive them. + return Ok(()); + } +``` + +- [ ] **Step 3: Build + manual test** + +Run: `cargo build` (zero warnings), `cargo test --lib` (no regressions; the nav logic itself is covered by Task 1's reducer tests). +Manual: attach to a Claude workspace with some edits. `Ctrl-x →` (default right-side bar) focuses the bar (top entry highlighted). `j`/`k`/arrows move; `g`/`G` jump; `Enter` expands; `↓` into the detail, `Enter` opens the editor at the line; `Esc` or `Ctrl-x ←` exits back to the agent. Confirm typing is NOT sent to the agent while focused. + +- [ ] **Step 4: Commit** + +```bash +git add src/app/input.rs +git commit -m "feat(chronology): keyboard focus enter/exit + in-pane list navigation" +``` + +--- + +## Task 7: Input — mouse mirrors keyboard + +**Files:** +- Modify: `src/app/input.rs` + +- [ ] **Step 1: Update the click handling** + +In `handle_mouse`'s `Down(Left)` chain, the first branch currently hits `chronology_entry_rects` and (per the base feature) toggles expand / opens on second click. Replace that branch's body so it mirrors the keyboard model: + +- A click on the **detail rect** opens the editor (check `chronology_detail_rect` FIRST, since the detail lies within/below the entry area). +- Otherwise a click on an **entry header rect** focuses the bar, selects that entry, and expands it. + +```rust + // Chronology: detail click opens; header click selects+expands. + if let Some((idx, _)) = app.chronology_detail_rect.filter(|(_, r)| { + m.column >= r.x && m.column < r.x.saturating_add(r.width) + && m.row >= r.y && m.row < r.y.saturating_add(r.height) + }) { + if let Some((worktree, file, detail)) = focused_attached_workspace(app) + .and_then(|(ws_id, worktree)| { + app.chronology.get(&ws_id).and_then(|t| { + t.events().get(idx).map(|ev| (worktree, ev.file_path.clone(), ev.detail.clone())) + }) + }) + { + let line = crate::activity::chronology::resolve_line_in_file(&file, &detail); + let editor = app.store.get_setting("editor_cmd").ok().flatten(); + if let Err(e) = crate::commands::external::open_in_editor_at(&worktree, &file, line, editor.as_deref()) { + tracing::warn!(error = %e, "failed to open editor from chronology detail click"); + } + } + app.chronology_focused = true; + app.chronology_sel = crate::ui::chronology_nav::ChronoSel::Detail(idx); + return Ok(()); + } else if let Some(idx) = app.chronology_entry_rects.iter().find_map(|(i, r)| { + let hit = m.column >= r.x && m.column < r.x.saturating_add(r.width) + && m.row >= r.y && m.row < r.y.saturating_add(r.height); + hit.then_some(*i) + }) { + app.chronology_focused = true; + app.chronology_sel = crate::ui::chronology_nav::ChronoSel::Entry(idx); + app.chronology_expanded = Some(idx); + return Ok(()); + } else if let Some(idx) = app.chip_rects.iter().position(|r| { + // ... existing chip_rects branch, unchanged, remains the next else-if ... +``` + +Preserve the rest of the chain (chip_rects → attention_rects → agent_chip_rects → …) exactly; only the chronology branch changes (it splits into the detail-first / header-second pair above). Read the current chain and integrate carefully. + +- [ ] **Step 2: Build + manual test** + +Run: `cargo build` (zero warnings), `cargo test --lib` (no regressions). +Manual: click an entry → it focuses+selects+expands; click the expanded diff peek → editor opens at the line. Wheel still scrolls. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/input.rs +git commit -m "feat(chronology): mouse mirrors keyboard (click entry expands, click detail opens)" +``` + +--- + +## Task 8: README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Document keyboard nav + mouse model** + +In the existing "Change chronology" subsection (and the attached-view keybindings table), document: +- `Ctrl-x` + arrow toward the bar's side focuses it; arrow away or `Esc` exits. +- While focused: `↑`/`k`, `↓`/`j` move; `g`/`G` top/bottom; `Enter` expands an entry; arrow into the detail and `Enter` again opens the editor at the changed line (new file → top); other keys don't reach the agent while focused. +- Mouse: click an entry to expand it; click the expanded detail to open the file at the line; wheel scrolls. + +Match the README's existing prose/table style. Add the new keybindings as rows in the attached-view table alongside `Ctrl-x c` / `Ctrl-x C`. + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: document chronology keyboard navigation and mouse model" +``` + +--- + +## Self-Review (completed during planning) + +**Spec coverage:** +- Focus enter/exit via `Ctrl-x`+arrow (toward/away, edge-aware via `focus_direction` return) → Task 6. ✓ +- List nav arrows/`j`/`k`/`g`/`G` → Task 1 (reducer) + Task 6 (key map). ✓ +- Two-level cursor (`Entry`/`Detail`), into-detail on `↓` when expanded → Task 1 (`nav`). ✓ +- `Enter` expand; `Enter` on detail opens → Task 1 + Task 6. ✓ +- Mouse mirrors (entry=select+expand, detail=open) → Task 7. ✓ +- Selection/focus highlight + active header → Task 2 (`EntryHighlight`) + Task 3 (render). ✓ +- Detail rect for mouse open + visible count for auto-scroll → Task 3. ✓ +- Auto-scroll keeps selection visible → Task 1 (`adjust_scroll`) + Task 5. ✓ +- Lifecycle reset on workspace change; clamp; drop focus when bar hidden → Task 4 (reset) + Task 6 (`chronology_bar_rect.is_some()` gate) + bounds-safe reducer. ✓ +- Testing: pure reducer + adjust_scroll + highlight tests → Tasks 1, 2. ✓ + +**Placeholder scan:** No TBDs. Where exact local/variable names in `render.rs`/`input.rs` can't be reproduced verbatim, the step shows the full code and instructs to match the actual surrounding names — the code to write is concrete, not deferred. The cross-task compile ordering (Task 2 folds the `attached.rs` `entry_lines` arity bump; Task 3 folds the `render.rs` `ChronologyDraw` field defaults) is called out explicitly so the tree always compiles. + +**Type consistency:** `ChronoSel` (`Entry`/`Detail`, `.index()`), `NavKey`, `NavAction` (`None`/`Expand`/`Collapse`/`Open`/`Exit`), `nav`, `adjust_scroll`, `EntryHighlight` (`None`/`Header`/`Detail`), `entry_lines(.., EntryHighlight)`, `ChronologyDraw { .., focused, sel }`, `ChronologyHits { entries, detail, visible_entries }`, `PanesDrawOutput { .., chronology_detail_rect, chronology_visible_entries }`, `App { .., chronology_focused, chronology_sel, chronology_detail_rect, chronology_visible_entries }` — names are consistent across tasks. From bd3aba9db2279d202117364eb791558f4c75e106 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 16:47:07 -0400 Subject: [PATCH 27/69] feat(chronology): pure keyboard-nav reducer (ChronoSel/nav/adjust_scroll) --- src/ui/chronology_nav.rs | 178 +++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + 2 files changed, 179 insertions(+) create mode 100644 src/ui/chronology_nav.rs diff --git a/src/ui/chronology_nav.rs b/src/ui/chronology_nav.rs new file mode 100644 index 0000000..b9fab22 --- /dev/null +++ b/src/ui/chronology_nav.rs @@ -0,0 +1,178 @@ +//! Pure cursor state machine for keyboard navigation of the chronology bar. +//! Kept free of `App`/`ratatui` so every transition is unit-testable. +//! +//! See `docs/superpowers/specs/2026-06-05-chronology-keyboard-navigation-design.md`. + +/// In-pane cursor while the chronology bar is keyboard-focused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChronoSel { + Entry(usize), + Detail(usize), +} + +impl Default for ChronoSel { + fn default() -> Self { + ChronoSel::Entry(0) + } +} + +impl ChronoSel { + /// The entry index this cursor refers to (entry or its detail). + pub fn index(self) -> usize { + match self { + ChronoSel::Entry(i) | ChronoSel::Detail(i) => i, + } + } +} + +/// A navigation key, already mapped from the raw keystroke. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavKey { + Up, + Down, + Top, + Bottom, + Enter, + Esc, +} + +/// Side effect the caller must apply after a transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavAction { + None, + Expand(usize), + Collapse(usize), + Open(usize), + Exit, +} + +/// Pure transition. `expanded` is the currently-expanded entry index (the bar +/// allows one at a time); `len` is the entry count. Bounds-safe: never returns +/// an index >= `len` (when `len > 0`). +pub fn nav(sel: ChronoSel, key: NavKey, expanded: Option, len: usize) -> (ChronoSel, NavAction) { + if key == NavKey::Esc { + return (sel, NavAction::Exit); + } + if len == 0 { + return (sel, NavAction::None); + } + let last = len - 1; + match (sel, key) { + (ChronoSel::Entry(i), NavKey::Down) => { + if expanded == Some(i) { + (ChronoSel::Detail(i), NavAction::None) + } else { + (ChronoSel::Entry((i + 1).min(last)), NavAction::None) + } + } + (ChronoSel::Detail(i), NavKey::Down) => (ChronoSel::Entry((i + 1).min(last)), NavAction::None), + (ChronoSel::Detail(i), NavKey::Up) => (ChronoSel::Entry(i), NavAction::None), + (ChronoSel::Entry(i), NavKey::Up) => (ChronoSel::Entry(i.saturating_sub(1)), NavAction::None), + (_, NavKey::Top) => (ChronoSel::Entry(0), NavAction::None), + (_, NavKey::Bottom) => (ChronoSel::Entry(last), NavAction::None), + (ChronoSel::Entry(i), NavKey::Enter) => { + if expanded == Some(i) { + (ChronoSel::Entry(i), NavAction::Collapse(i)) + } else { + (ChronoSel::Entry(i), NavAction::Expand(i)) + } + } + (ChronoSel::Detail(i), NavKey::Enter) => (ChronoSel::Detail(i), NavAction::Open(i)), + (_, NavKey::Esc) => unreachable!(), + } +} + +/// Adjust the viewport `scroll` so the selected entry index stays visible, +/// given how many entries were visible last frame. One-frame lag is fine. +pub fn adjust_scroll(scroll: usize, sel_index: usize, visible: usize, len: usize) -> usize { + if len == 0 { + return 0; + } + if sel_index < scroll { + return sel_index; + } + if visible > 0 && sel_index >= scroll + visible { + return sel_index + 1 - visible; + } + scroll +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn down_moves_to_next_entry_when_collapsed() { + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Down, None, 3), (ChronoSel::Entry(1), NavAction::None)); + } + + #[test] + fn down_steps_into_detail_when_expanded() { + assert_eq!(nav(ChronoSel::Entry(1), NavKey::Down, Some(1), 3), (ChronoSel::Detail(1), NavAction::None)); + } + + #[test] + fn down_from_detail_goes_to_next_entry() { + assert_eq!(nav(ChronoSel::Detail(1), NavKey::Down, Some(1), 3), (ChronoSel::Entry(2), NavAction::None)); + } + + #[test] + fn up_from_detail_returns_to_entry() { + assert_eq!(nav(ChronoSel::Detail(2), NavKey::Up, Some(2), 3), (ChronoSel::Entry(2), NavAction::None)); + } + + #[test] + fn up_from_entry_goes_previous_saturating() { + assert_eq!(nav(ChronoSel::Entry(1), NavKey::Up, None, 3), (ChronoSel::Entry(0), NavAction::None)); + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Up, None, 3), (ChronoSel::Entry(0), NavAction::None)); + } + + #[test] + fn down_clamps_at_last() { + assert_eq!(nav(ChronoSel::Entry(2), NavKey::Down, None, 3), (ChronoSel::Entry(2), NavAction::None)); + } + + #[test] + fn top_and_bottom() { + assert_eq!(nav(ChronoSel::Detail(1), NavKey::Top, Some(1), 3), (ChronoSel::Entry(0), NavAction::None)); + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Bottom, None, 3), (ChronoSel::Entry(2), NavAction::None)); + } + + #[test] + fn enter_toggles_expand_on_entry() { + assert_eq!(nav(ChronoSel::Entry(1), NavKey::Enter, None, 3), (ChronoSel::Entry(1), NavAction::Expand(1))); + assert_eq!(nav(ChronoSel::Entry(1), NavKey::Enter, Some(1), 3), (ChronoSel::Entry(1), NavAction::Collapse(1))); + } + + #[test] + fn enter_on_detail_opens() { + assert_eq!(nav(ChronoSel::Detail(1), NavKey::Enter, Some(1), 3), (ChronoSel::Detail(1), NavAction::Open(1))); + } + + #[test] + fn esc_exits_from_anywhere() { + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Esc, None, 3).1, NavAction::Exit); + assert_eq!(nav(ChronoSel::Detail(2), NavKey::Esc, Some(2), 3).1, NavAction::Exit); + } + + #[test] + fn empty_list_only_exits() { + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Down, None, 0), (ChronoSel::Entry(0), NavAction::None)); + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Enter, None, 0), (ChronoSel::Entry(0), NavAction::None)); + assert_eq!(nav(ChronoSel::Entry(0), NavKey::Esc, None, 0).1, NavAction::Exit); + } + + #[test] + fn adjust_scroll_keeps_selection_visible() { + assert_eq!(adjust_scroll(5, 2, 4, 10), 2); + assert_eq!(adjust_scroll(0, 6, 4, 10), 3); + assert_eq!(adjust_scroll(2, 3, 4, 10), 2); + assert_eq!(adjust_scroll(3, 0, 4, 0), 0); + } + + #[test] + fn index_extracts_entry_index() { + assert_eq!(ChronoSel::Entry(4).index(), 4); + assert_eq!(ChronoSel::Detail(7).index(), 7); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 98f77a1..938a1d9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,6 @@ pub mod attached; pub mod chronology_bar; +pub mod chronology_nav; pub mod dashboard; pub mod modal; pub mod pm_pane; From ed635746ce544ee8743c71f6a398d5a89aff4332 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 16:49:11 -0400 Subject: [PATCH 28/69] feat(chronology): EntryHighlight arg on entry_lines for selection rendering --- src/ui/attached.rs | 2 +- src/ui/chronology_bar.rs | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/ui/attached.rs b/src/ui/attached.rs index 4de5c4c..998086b 100644 --- a/src/ui/attached.rs +++ b/src/ui/attached.rs @@ -279,7 +279,7 @@ fn render_chronology_bar( } let expanded = Some(i) == draw.expanded; let lines = - crate::ui::chronology_bar::entry_lines(ev, draw.worktree, expanded, inner_width); + crate::ui::chronology_bar::entry_lines(ev, draw.worktree, expanded, inner_width, crate::ui::chronology_bar::EntryHighlight::None); let available = body_bottom.saturating_sub(cursor_y); let drawn = (lines.len() as u16).min(available); if drawn == 0 { diff --git a/src/ui/chronology_bar.rs b/src/ui/chronology_bar.rs index 75bae13..69d192a 100644 --- a/src/ui/chronology_bar.rs +++ b/src/ui/chronology_bar.rs @@ -34,6 +34,15 @@ fn hhmm(timestamp_ms: i64) -> String { format!("{h:02}:{m:02}") } +/// Which part of an entry is keyboard-selected (for highlight). `None` when the +/// entry isn't the cursor target. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryHighlight { + None, + Header, + Detail, +} + /// Render one entry into lines. Line 1: `HH:MM file`. Line 2: dim summary. /// When `expanded`, appends up to a few diff-peek lines from `detail`. pub fn entry_lines( @@ -41,6 +50,7 @@ pub fn entry_lines( worktree: &Path, expanded: bool, width: u16, + highlight: EntryHighlight, ) -> Vec> { let mut out = Vec::new(); let rel = relative_display(&ev.file_path, worktree); @@ -81,6 +91,24 @@ pub fn entry_lines( ))); } } + match highlight { + EntryHighlight::None => {} + EntryHighlight::Header => { + if let Some(first) = out.first_mut() { + for s in &mut first.spans { + s.style = s.style.add_modifier(Modifier::REVERSED); + } + } + } + EntryHighlight::Detail => { + // peek lines are everything after the header (0) and summary (1) + for line in out.iter_mut().skip(2) { + for s in &mut line.spans { + s.style = s.style.add_modifier(Modifier::REVERSED); + } + } + } + } out } @@ -128,6 +156,7 @@ mod tests { Path::new("/wt"), false, 40, + EntryHighlight::None, ); assert_eq!(lines.len(), 2, "B fidelity: header + summary, no diff peek"); } @@ -139,7 +168,41 @@ mod tests { Path::new("/wt"), true, 40, + EntryHighlight::None, ); assert!(lines.len() > 2, "expanded entry includes diff peek"); } + + #[test] + fn header_highlight_reverses_first_line() { + let lines = entry_lines(&ev("/wt/a.rs", "fn foo()"), Path::new("/wt"), true, 40, EntryHighlight::Header); + let has_rev = lines[0] + .spans + .iter() + .any(|s| s.style.add_modifier.contains(Modifier::REVERSED)); + assert!(has_rev, "header line should be highlighted"); + } + + #[test] + fn detail_highlight_reverses_peek_lines_only() { + let lines = entry_lines(&ev("/wt/a.rs", "fn foo()"), Path::new("/wt"), true, 40, EntryHighlight::Detail); + assert!( + !lines[0].spans.iter().any(|s| s.style.add_modifier.contains(Modifier::REVERSED)), + "header must NOT be highlighted in Detail mode" + ); + let peek_rev = lines + .iter() + .skip(2) + .any(|l| l.spans.iter().any(|s| s.style.add_modifier.contains(Modifier::REVERSED))); + assert!(peek_rev, "detail peek should be highlighted"); + } + + #[test] + fn no_highlight_leaves_lines_unreversed() { + let lines = entry_lines(&ev("/wt/a.rs", "fn foo()"), Path::new("/wt"), false, 40, EntryHighlight::None); + assert!( + !lines.iter().any(|l| l.spans.iter().any(|s| s.style.add_modifier.contains(Modifier::REVERSED))), + "no highlight expected" + ); + } } From 6e936864472b3e7e463457c81fc1f444f29cd29b Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 16:52:43 -0400 Subject: [PATCH 29/69] feat(chronology): focus/selection highlight + detail-rect/visible-count in bar rendering --- src/app/render.rs | 2 + src/ui/attached.rs | 118 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 103 insertions(+), 17 deletions(-) diff --git a/src/app/render.rs b/src/app/render.rs index 5fe3d14..6028202 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -565,6 +565,8 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { worktree, scroll: chronology_scroll, expanded: chronology_expanded, + focused: false, + sel: Default::default(), }), _ => None, }; diff --git a/src/ui/attached.rs b/src/ui/attached.rs index 998086b..8523ada 100644 --- a/src/ui/attached.rs +++ b/src/ui/attached.rs @@ -33,6 +33,20 @@ pub struct ChronologyDraw<'a> { pub worktree: &'a std::path::Path, pub scroll: usize, pub expanded: Option, + /// Keyboard focus is in the bar (drives the active header + selection highlight). + pub focused: bool, + /// In-pane cursor while focused. + pub sel: crate::ui::chronology_nav::ChronoSel, +} + +/// Mouse/scroll hit targets produced by painting the chronology bar. +pub struct ChronologyHits { + /// `(entry_index, header_rect)` per drawn entry. + pub entries: Vec<(usize, Rect)>, + /// The expanded entry's detail block `(entry_index, rect)`, if any was drawn. + pub detail: Option<(usize, Rect)>, + /// Number of entries drawn this frame (for auto-scroll). + pub visible_entries: usize, } /// What `render_panes` reports back to the caller for input hit-testing. @@ -48,6 +62,10 @@ pub struct PanesDrawOutput { /// `(entry_index, clickable_rect)` for each rendered chronology entry in the /// focused pane's bar. Empty when the bar isn't shown. pub chronology_entry_rects: Vec<(usize, Rect)>, + /// The expanded entry's detail rect (for mouse "open at line"), if shown. + pub chronology_detail_rect: Option<(usize, Rect)>, + /// Entries drawn in the chronology bar this frame (for keyboard auto-scroll). + pub chronology_visible_entries: usize, } /// Split `area` into `(agent_area, Some(bar_rect))` per the chronology config, @@ -138,9 +156,13 @@ pub fn render_panes( render_dividers(f, dividers, theme); - let chronology_entry_rects = match chronology_bar { + let chronology_hits = match chronology_bar { Some((bar_rect, draw)) => render_chronology_bar(f, bar_rect, &draw, theme), - None => Vec::new(), + None => ChronologyHits { + entries: Vec::new(), + detail: None, + visible_entries: 0, + }, }; if let Some(line) = attention_line { @@ -176,24 +198,30 @@ pub fn render_panes( chip_rects, pane_rects, agent_chip_rects, - chronology_entry_rects, + chronology_entry_rects: chronology_hits.entries, + chronology_detail_rect: chronology_hits.detail, + chronology_visible_entries: chronology_hits.visible_entries, } } /// Paint the change-chronology bar into `bar_rect` (absolute screen coords): /// a 1-col divider on the bar's inner edge, a `CHANGE CHRONOLOGY` header with a /// side indicator, then the timeline entries from `draw.scroll` down. Returns -/// `(entry_index, header_rect)` for each rendered entry's first (header) line, -/// in absolute screen coords, for later click hit-testing. Entries that don't -/// fit vertically are dropped from the end. +/// [`ChronologyHits`] with per-entry header rects, the expanded detail rect, and +/// visible entry count — all for click hit-testing and keyboard auto-scroll. +/// Entries that don't fit vertically are dropped from the end. fn render_chronology_bar( f: &mut Frame, bar_rect: Rect, draw: &ChronologyDraw<'_>, theme: &Theme, -) -> Vec<(usize, Rect)> { +) -> ChronologyHits { if bar_rect.width == 0 || bar_rect.height == 0 { - return Vec::new(); + return ChronologyHits { + entries: Vec::new(), + detail: None, + visible_entries: 0, + }; } // The divider occupies the column on the bar's inner edge (next to the @@ -229,7 +257,11 @@ fn render_chronology_bar( } }; if content.width == 0 || content.height == 0 { - return Vec::new(); + return ChronologyHits { + entries: Vec::new(), + detail: None, + visible_entries: 0, + }; } let inner_width = content.width; @@ -241,8 +273,15 @@ fn render_chronology_bar( height: 1, ..content }; + let header_style = if draw.focused { + theme + .header_style() + .add_modifier(ratatui::style::Modifier::BOLD) + } else { + theme.header_style() + }; f.render_widget( - Paragraph::new(Line::from(Span::styled(header_text, theme.header_style()))), + Paragraph::new(Line::from(Span::styled(header_text, header_style))), header_area, ); @@ -250,11 +289,13 @@ fn render_chronology_bar( let body_y = content.y.saturating_add(1); let body_bottom = content.y.saturating_add(content.height); if body_y >= body_bottom { - return Vec::new(); + return ChronologyHits { + entries: Vec::new(), + detail: None, + visible_entries: 0, + }; } - let mut entry_rects: Vec<(usize, Rect)> = Vec::new(); - if draw.events.is_empty() { let placeholder = Rect { x: content.x, @@ -269,17 +310,40 @@ fn render_chronology_bar( ))), placeholder, ); - return entry_rects; + return ChronologyHits { + entries: Vec::new(), + detail: None, + visible_entries: 0, + }; } + use crate::ui::chronology_bar::EntryHighlight; + use crate::ui::chronology_nav::ChronoSel; + let mut entry_rects: Vec<(usize, Rect)> = Vec::new(); + let mut detail_rect: Option<(usize, Rect)> = None; + let mut visible_entries = 0usize; let mut cursor_y = body_y; for (i, ev) in draw.events.iter().enumerate().skip(draw.scroll) { if cursor_y >= body_bottom { break; } let expanded = Some(i) == draw.expanded; - let lines = - crate::ui::chronology_bar::entry_lines(ev, draw.worktree, expanded, inner_width, crate::ui::chronology_bar::EntryHighlight::None); + let highlight = if draw.focused { + match draw.sel { + ChronoSel::Entry(s) if s == i => EntryHighlight::Header, + ChronoSel::Detail(s) if s == i => EntryHighlight::Detail, + _ => EntryHighlight::None, + } + } else { + EntryHighlight::None + }; + let lines = crate::ui::chronology_bar::entry_lines( + ev, + draw.worktree, + expanded, + inner_width, + highlight, + ); let available = body_bottom.saturating_sub(cursor_y); let drawn = (lines.len() as u16).min(available); if drawn == 0 { @@ -302,10 +366,26 @@ fn render_chronology_bar( height: 1, }, )); + if expanded && drawn > 1 { + detail_rect = Some(( + i, + Rect { + x: content.x, + y: cursor_y.saturating_add(1), + width: inner_width, + height: drawn - 1, + }, + )); + } + visible_entries += 1; cursor_y = cursor_y.saturating_add(drawn); } - entry_rects + ChronologyHits { + entries: entry_rects, + detail: detail_rect, + visible_entries, + } } fn render_one_pane(f: &mut Frame, pane: &PaneSpec<'_>, show_title: bool, theme: &Theme) -> Rect { @@ -763,6 +843,8 @@ mod tests { worktree: std::path::Path::new("/wt"), scroll: 0, expanded: None, + focused: false, + sel: Default::default(), }; let area = Rect { x: 0, @@ -786,6 +868,8 @@ mod tests { worktree: std::path::Path::new("/wt"), scroll: 0, expanded: None, + focused: false, + sel: Default::default(), }; let area = Rect { x: 0, From 91ce9288b6134cc1958b7ac1be86427df1111371 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 16:57:09 -0400 Subject: [PATCH 30/69] feat(chronology): app state for keyboard focus + selection --- src/app.rs | 17 +++++++++++++++++ src/app/render.rs | 2 ++ 2 files changed, 19 insertions(+) diff --git a/src/app.rs b/src/app.rs index cbbb632..4f0917b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -181,6 +181,15 @@ pub struct App { /// Transient per-frame screen rect of the chronology bar (focused pane), /// used for wheel-scroll hit-testing. `None` when the bar isn't shown. pub chronology_bar_rect: Option, + /// Keyboard focus is in the chronology bar (intercept nav keys). + pub chronology_focused: bool, + /// In-pane cursor while focused. + pub chronology_sel: crate::ui::chronology_nav::ChronoSel, + /// Transient per-frame detail rect of the expanded entry `(index, rect)`, + /// for mouse "open at line". `None` when nothing is expanded/shown. + pub chronology_detail_rect: Option<(usize, ratatui::layout::Rect)>, + /// Entries drawn in the bar last frame (for keyboard auto-scroll). + pub chronology_visible_entries: usize, /// Per-workspace tracking for attention-alert state. pub workspace_activity: std::collections::HashMap, @@ -336,6 +345,10 @@ impl App { chronology_last_workspace: None, chronology_entry_rects: Vec::new(), chronology_bar_rect: None, + chronology_focused: false, + chronology_sel: crate::ui::chronology_nav::ChronoSel::default(), + chronology_detail_rect: None, + chronology_visible_entries: 0, workspace_activity: std::collections::HashMap::new(), workspace_events_scanned: std::collections::HashSet::new(), workspace_needs_attention: std::collections::HashSet::new(), @@ -648,12 +661,16 @@ pub(crate) fn reset_detail_scroll_on_workspace_change( pub(crate) fn reset_chronology_state_on_workspace_change( scroll: &mut usize, expanded: &mut Option, + focused: &mut bool, + sel: &mut crate::ui::chronology_nav::ChronoSel, last_workspace: &mut Option, current: Option, ) { if *last_workspace != current { *scroll = 0; *expanded = None; + *focused = false; + *sel = crate::ui::chronology_nav::ChronoSel::Entry(0); *last_workspace = current; } } diff --git a/src/app/render.rs b/src/app/render.rs index 6028202..14eb77a 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -547,6 +547,8 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { crate::app::reset_chronology_state_on_workspace_change( &mut app.chronology_scroll, &mut app.chronology_expanded, + &mut app.chronology_focused, + &mut app.chronology_sel, &mut app.chronology_last_workspace, Some(focused_id), ); From 56287f67a5afc1186a9178c07cfc5d8e46a0cfa4 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 16:59:13 -0400 Subject: [PATCH 31/69] feat(chronology): wire focus/selection + auto-scroll into the attached render --- src/app/render.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/render.rs b/src/app/render.rs index 14eb77a..afaf87e 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -34,6 +34,7 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { app.agent_chip_rects.clear(); app.chronology_entry_rects.clear(); app.chronology_bar_rect = None; + app.chronology_detail_rect = None; app.usage_graph_rect = None; app.usage_window_option_rects.clear(); @@ -552,6 +553,15 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { &mut app.chronology_last_workspace, Some(focused_id), ); + // Keep the keyboard selection in view (uses last frame's visible count). + if app.chronology_focused { + app.chronology_scroll = crate::ui::chronology_nav::adjust_scroll( + app.chronology_scroll, + app.chronology_sel.index(), + app.chronology_visible_entries, + chronology_events.len(), + ); + } let chronology_scroll = app.chronology_scroll; let chronology_expanded = app.chronology_expanded; @@ -567,8 +577,8 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { worktree, scroll: chronology_scroll, expanded: chronology_expanded, - focused: false, - sel: Default::default(), + focused: app.chronology_focused, + sel: app.chronology_sel, }), _ => None, }; @@ -653,6 +663,8 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { app.attached_pane_rects = out.pane_rects; app.agent_chip_rects = out.agent_chip_rects; app.chronology_entry_rects = out.chronology_entry_rects; + app.chronology_detail_rect = out.chronology_detail_rect; + app.chronology_visible_entries = out.chronology_visible_entries; app.chronology_bar_rect = bar_rect; app.pinned_commands_cache = pinned; } From f34e5ba923ef3737b9937b1f62c1c82df241c1b8 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 17:02:51 -0400 Subject: [PATCH 32/69] feat(chronology): keyboard focus enter/exit + in-pane list navigation --- src/app/input.rs | 99 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/src/app/input.rs b/src/app/input.rs index 826ecf6..19ffab5 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -288,6 +288,18 @@ fn focused_attached_workspace( Some((ws_id, worktree)) } +/// Resolve the configured chronology side for the focused attached workspace. +fn focused_chronology_side(app: &App) -> Option { + let crate::ui::View::Attached(state) = &app.view else { + return None; + }; + let target = state.focused_target()?; + let ws_id = target.workspace_id; + let (rid, _w) = app.workspaces.iter().find(|(_, w)| w.id == ws_id)?; + let repo = app.repos.iter().find(|r| r.id == *rid)?; + Some(crate::config::chronology::resolve(repo, &app.store).side) +} + /// Vim-style `h` (fold) / `l` (unfold) on the focused row. Unlike /// [`toggle_focused_fold`], this is idempotent: pressing `h` on an /// already-folded repo leaves it folded. @@ -810,6 +822,39 @@ async fn handle_key_attached( KeyCode::Down => Arrow::Down, _ => unreachable!(), }; + use crate::config::chronology::Side; + let side = focused_chronology_side(app); + let toward_bar = matches!( + (side, arrow), + (Some(Side::Right), Arrow::Right) | (Some(Side::Left), Arrow::Left) + ); + let away_from_bar = matches!( + (side, arrow), + (Some(Side::Right), Arrow::Left) | (Some(Side::Left), Arrow::Right) + ); + if app.chronology_focused { + if away_from_bar { + app.chronology_focused = false; // focus returns to the agent pane + } + // toward/parallel arrows while focused: ignored + return Ok(()); + } + if toward_bar && app.chronology_bar_rect.is_some() { + // Enter the bar only if there's no agent pane further toward it + // (we're at the edge). focus_direction returns false when it + // could not move. + let moved = if let View::Attached(state) = &mut app.view { + state.focus_direction(arrow) + } else { + false + }; + if !moved { + app.chronology_focused = true; + app.chronology_sel = crate::ui::chronology_nav::ChronoSel::Entry(0); + app.chronology_scroll = 0; + } + return Ok(()); + } if let View::Attached(state) = &mut app.view { state.focus_direction(arrow); } @@ -951,6 +996,60 @@ async fn handle_key_attached( app.leader_pending = true; return Ok(()); } + if app.chronology_focused { + use crate::ui::chronology_nav::{NavAction, NavKey, nav}; + let navkey = match k.code { + KeyCode::Down | KeyCode::Char('j') => Some(NavKey::Down), + KeyCode::Up | KeyCode::Char('k') => Some(NavKey::Up), + KeyCode::Char('g') => Some(NavKey::Top), + KeyCode::Char('G') => Some(NavKey::Bottom), + KeyCode::Enter => Some(NavKey::Enter), + KeyCode::Esc => Some(NavKey::Esc), + _ => None, + }; + if let Some(navkey) = navkey { + // Compute the entry count first (immutable borrow), then mutate. + let len = focused_attached_workspace(app) + .and_then(|(id, _)| app.chronology.get(&id)) + .map(|t| t.events().len()) + .unwrap_or(0); + let (new_sel, action) = nav(app.chronology_sel, navkey, app.chronology_expanded, len); + app.chronology_sel = new_sel; + match action { + NavAction::None => {} + NavAction::Expand(i) => app.chronology_expanded = Some(i), + NavAction::Collapse(_) => app.chronology_expanded = None, + NavAction::Exit => app.chronology_focused = false, + NavAction::Open(i) => { + // Clone path+detail out of the chronology borrow before + // touching app.store / spawning the editor. + if let Some((worktree, file, detail)) = focused_attached_workspace(app) + .and_then(|(ws_id, worktree)| { + app.chronology.get(&ws_id).and_then(|t| { + t.events() + .get(i) + .map(|ev| (worktree, ev.file_path.clone(), ev.detail.clone())) + }) + }) + { + let line = + crate::activity::chronology::resolve_line_in_file(&file, &detail); + let editor = app.store.get_setting("editor_cmd").ok().flatten(); + if let Err(e) = crate::commands::external::open_in_editor_at( + &worktree, + &file, + line, + editor.as_deref(), + ) { + tracing::warn!(error = %e, "failed to open editor from chronology keyboard"); + } + } + } + } + } + // While focused, swallow ALL keys — the agent PTY must not receive them. + return Ok(()); + } let bytes = encode_key(k); if !bytes.is_empty() { session.scroll_to_live(); From 8b29e588546a955a0344b6e87885ca03ceef0523 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 17:03:42 -0400 Subject: [PATCH 33/69] style: cargo fmt chronology_nav and chronology_bar --- src/ui/chronology_bar.rs | 43 +++++++++++++---- src/ui/chronology_nav.rs | 100 +++++++++++++++++++++++++++++++-------- 2 files changed, 114 insertions(+), 29 deletions(-) diff --git a/src/ui/chronology_bar.rs b/src/ui/chronology_bar.rs index 69d192a..2e06297 100644 --- a/src/ui/chronology_bar.rs +++ b/src/ui/chronology_bar.rs @@ -175,7 +175,13 @@ mod tests { #[test] fn header_highlight_reverses_first_line() { - let lines = entry_lines(&ev("/wt/a.rs", "fn foo()"), Path::new("/wt"), true, 40, EntryHighlight::Header); + let lines = entry_lines( + &ev("/wt/a.rs", "fn foo()"), + Path::new("/wt"), + true, + 40, + EntryHighlight::Header, + ); let has_rev = lines[0] .spans .iter() @@ -185,23 +191,42 @@ mod tests { #[test] fn detail_highlight_reverses_peek_lines_only() { - let lines = entry_lines(&ev("/wt/a.rs", "fn foo()"), Path::new("/wt"), true, 40, EntryHighlight::Detail); + let lines = entry_lines( + &ev("/wt/a.rs", "fn foo()"), + Path::new("/wt"), + true, + 40, + EntryHighlight::Detail, + ); assert!( - !lines[0].spans.iter().any(|s| s.style.add_modifier.contains(Modifier::REVERSED)), + !lines[0] + .spans + .iter() + .any(|s| s.style.add_modifier.contains(Modifier::REVERSED)), "header must NOT be highlighted in Detail mode" ); - let peek_rev = lines - .iter() - .skip(2) - .any(|l| l.spans.iter().any(|s| s.style.add_modifier.contains(Modifier::REVERSED))); + let peek_rev = lines.iter().skip(2).any(|l| { + l.spans + .iter() + .any(|s| s.style.add_modifier.contains(Modifier::REVERSED)) + }); assert!(peek_rev, "detail peek should be highlighted"); } #[test] fn no_highlight_leaves_lines_unreversed() { - let lines = entry_lines(&ev("/wt/a.rs", "fn foo()"), Path::new("/wt"), false, 40, EntryHighlight::None); + let lines = entry_lines( + &ev("/wt/a.rs", "fn foo()"), + Path::new("/wt"), + false, + 40, + EntryHighlight::None, + ); assert!( - !lines.iter().any(|l| l.spans.iter().any(|s| s.style.add_modifier.contains(Modifier::REVERSED))), + !lines.iter().any(|l| l + .spans + .iter() + .any(|s| s.style.add_modifier.contains(Modifier::REVERSED))), "no highlight expected" ); } diff --git a/src/ui/chronology_nav.rs b/src/ui/chronology_nav.rs index b9fab22..ee5497b 100644 --- a/src/ui/chronology_nav.rs +++ b/src/ui/chronology_nav.rs @@ -49,7 +49,12 @@ pub enum NavAction { /// Pure transition. `expanded` is the currently-expanded entry index (the bar /// allows one at a time); `len` is the entry count. Bounds-safe: never returns /// an index >= `len` (when `len > 0`). -pub fn nav(sel: ChronoSel, key: NavKey, expanded: Option, len: usize) -> (ChronoSel, NavAction) { +pub fn nav( + sel: ChronoSel, + key: NavKey, + expanded: Option, + len: usize, +) -> (ChronoSel, NavAction) { if key == NavKey::Esc { return (sel, NavAction::Exit); } @@ -65,9 +70,13 @@ pub fn nav(sel: ChronoSel, key: NavKey, expanded: Option, len: usize) -> (ChronoSel::Entry((i + 1).min(last)), NavAction::None) } } - (ChronoSel::Detail(i), NavKey::Down) => (ChronoSel::Entry((i + 1).min(last)), NavAction::None), + (ChronoSel::Detail(i), NavKey::Down) => { + (ChronoSel::Entry((i + 1).min(last)), NavAction::None) + } (ChronoSel::Detail(i), NavKey::Up) => (ChronoSel::Entry(i), NavAction::None), - (ChronoSel::Entry(i), NavKey::Up) => (ChronoSel::Entry(i.saturating_sub(1)), NavAction::None), + (ChronoSel::Entry(i), NavKey::Up) => { + (ChronoSel::Entry(i.saturating_sub(1)), NavAction::None) + } (_, NavKey::Top) => (ChronoSel::Entry(0), NavAction::None), (_, NavKey::Bottom) => (ChronoSel::Entry(last), NavAction::None), (ChronoSel::Entry(i), NavKey::Enter) => { @@ -103,63 +112,114 @@ mod tests { #[test] fn down_moves_to_next_entry_when_collapsed() { - assert_eq!(nav(ChronoSel::Entry(0), NavKey::Down, None, 3), (ChronoSel::Entry(1), NavAction::None)); + assert_eq!( + nav(ChronoSel::Entry(0), NavKey::Down, None, 3), + (ChronoSel::Entry(1), NavAction::None) + ); } #[test] fn down_steps_into_detail_when_expanded() { - assert_eq!(nav(ChronoSel::Entry(1), NavKey::Down, Some(1), 3), (ChronoSel::Detail(1), NavAction::None)); + assert_eq!( + nav(ChronoSel::Entry(1), NavKey::Down, Some(1), 3), + (ChronoSel::Detail(1), NavAction::None) + ); } #[test] fn down_from_detail_goes_to_next_entry() { - assert_eq!(nav(ChronoSel::Detail(1), NavKey::Down, Some(1), 3), (ChronoSel::Entry(2), NavAction::None)); + assert_eq!( + nav(ChronoSel::Detail(1), NavKey::Down, Some(1), 3), + (ChronoSel::Entry(2), NavAction::None) + ); } #[test] fn up_from_detail_returns_to_entry() { - assert_eq!(nav(ChronoSel::Detail(2), NavKey::Up, Some(2), 3), (ChronoSel::Entry(2), NavAction::None)); + assert_eq!( + nav(ChronoSel::Detail(2), NavKey::Up, Some(2), 3), + (ChronoSel::Entry(2), NavAction::None) + ); } #[test] fn up_from_entry_goes_previous_saturating() { - assert_eq!(nav(ChronoSel::Entry(1), NavKey::Up, None, 3), (ChronoSel::Entry(0), NavAction::None)); - assert_eq!(nav(ChronoSel::Entry(0), NavKey::Up, None, 3), (ChronoSel::Entry(0), NavAction::None)); + assert_eq!( + nav(ChronoSel::Entry(1), NavKey::Up, None, 3), + (ChronoSel::Entry(0), NavAction::None) + ); + assert_eq!( + nav(ChronoSel::Entry(0), NavKey::Up, None, 3), + (ChronoSel::Entry(0), NavAction::None) + ); } #[test] fn down_clamps_at_last() { - assert_eq!(nav(ChronoSel::Entry(2), NavKey::Down, None, 3), (ChronoSel::Entry(2), NavAction::None)); + assert_eq!( + nav(ChronoSel::Entry(2), NavKey::Down, None, 3), + (ChronoSel::Entry(2), NavAction::None) + ); } #[test] fn top_and_bottom() { - assert_eq!(nav(ChronoSel::Detail(1), NavKey::Top, Some(1), 3), (ChronoSel::Entry(0), NavAction::None)); - assert_eq!(nav(ChronoSel::Entry(0), NavKey::Bottom, None, 3), (ChronoSel::Entry(2), NavAction::None)); + assert_eq!( + nav(ChronoSel::Detail(1), NavKey::Top, Some(1), 3), + (ChronoSel::Entry(0), NavAction::None) + ); + assert_eq!( + nav(ChronoSel::Entry(0), NavKey::Bottom, None, 3), + (ChronoSel::Entry(2), NavAction::None) + ); } #[test] fn enter_toggles_expand_on_entry() { - assert_eq!(nav(ChronoSel::Entry(1), NavKey::Enter, None, 3), (ChronoSel::Entry(1), NavAction::Expand(1))); - assert_eq!(nav(ChronoSel::Entry(1), NavKey::Enter, Some(1), 3), (ChronoSel::Entry(1), NavAction::Collapse(1))); + assert_eq!( + nav(ChronoSel::Entry(1), NavKey::Enter, None, 3), + (ChronoSel::Entry(1), NavAction::Expand(1)) + ); + assert_eq!( + nav(ChronoSel::Entry(1), NavKey::Enter, Some(1), 3), + (ChronoSel::Entry(1), NavAction::Collapse(1)) + ); } #[test] fn enter_on_detail_opens() { - assert_eq!(nav(ChronoSel::Detail(1), NavKey::Enter, Some(1), 3), (ChronoSel::Detail(1), NavAction::Open(1))); + assert_eq!( + nav(ChronoSel::Detail(1), NavKey::Enter, Some(1), 3), + (ChronoSel::Detail(1), NavAction::Open(1)) + ); } #[test] fn esc_exits_from_anywhere() { - assert_eq!(nav(ChronoSel::Entry(0), NavKey::Esc, None, 3).1, NavAction::Exit); - assert_eq!(nav(ChronoSel::Detail(2), NavKey::Esc, Some(2), 3).1, NavAction::Exit); + assert_eq!( + nav(ChronoSel::Entry(0), NavKey::Esc, None, 3).1, + NavAction::Exit + ); + assert_eq!( + nav(ChronoSel::Detail(2), NavKey::Esc, Some(2), 3).1, + NavAction::Exit + ); } #[test] fn empty_list_only_exits() { - assert_eq!(nav(ChronoSel::Entry(0), NavKey::Down, None, 0), (ChronoSel::Entry(0), NavAction::None)); - assert_eq!(nav(ChronoSel::Entry(0), NavKey::Enter, None, 0), (ChronoSel::Entry(0), NavAction::None)); - assert_eq!(nav(ChronoSel::Entry(0), NavKey::Esc, None, 0).1, NavAction::Exit); + assert_eq!( + nav(ChronoSel::Entry(0), NavKey::Down, None, 0), + (ChronoSel::Entry(0), NavAction::None) + ); + assert_eq!( + nav(ChronoSel::Entry(0), NavKey::Enter, None, 0), + (ChronoSel::Entry(0), NavAction::None) + ); + assert_eq!( + nav(ChronoSel::Entry(0), NavKey::Esc, None, 0).1, + NavAction::Exit + ); } #[test] From 80b527e6666915481c65d0dad22d0f0c19100c65 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 17:08:51 -0400 Subject: [PATCH 34/69] feat(chronology): mouse mirrors keyboard (click entry expands, click detail opens) --- src/app/input.rs | 62 +++++++++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/src/app/input.rs b/src/app/input.rs index 19ffab5..69d5a34 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -1905,39 +1905,47 @@ async fn handle_mouse(app: &mut App, m: MouseEvent) { return; } - if let Some(idx) = app.chronology_entry_rects.iter().find_map(|(i, r)| { + // Chronology detail click → open the file at the line (mirrors the + // keyboard `Enter`-on-detail). Checked first because the detail block + // lies below the entry's header row. + if let Some((idx, _)) = app.chronology_detail_rect.filter(|(_, r)| { + m.column >= r.x + && m.column < r.x.saturating_add(r.width) + && m.row >= r.y + && m.row < r.y.saturating_add(r.height) + }) { + let target = focused_attached_workspace(app).and_then(|(ws_id, worktree)| { + app.chronology.get(&ws_id).and_then(|t| { + t.events() + .get(idx) + .map(|ev| (worktree, ev.file_path.clone(), ev.detail.clone())) + }) + }); + if let Some((worktree, file, detail)) = target { + let line = crate::activity::chronology::resolve_line_in_file(&file, &detail); + let editor = app.store.get_setting("editor_cmd").ok().flatten(); + if let Err(e) = crate::commands::external::open_in_editor_at( + &worktree, + &file, + line, + editor.as_deref(), + ) { + tracing::warn!(error = %e, "failed to open editor from chronology detail click"); + } + } + app.chronology_focused = true; + app.chronology_sel = crate::ui::chronology_nav::ChronoSel::Detail(idx); + } else if let Some(idx) = app.chronology_entry_rects.iter().find_map(|(i, r)| { let hit = m.column >= r.x && m.column < r.x.saturating_add(r.width) && m.row >= r.y && m.row < r.y.saturating_add(r.height); hit.then_some(*i) }) { - if app.chronology_expanded == Some(idx) { - // Second click on the already-expanded entry → open editor. - // Clone path+detail before any further `app` borrow. - let target = focused_attached_workspace(app).and_then(|(ws_id, worktree)| { - app.chronology.get(&ws_id).and_then(|t| { - t.events() - .get(idx) - .map(|ev| (worktree, ev.file_path.clone(), ev.detail.clone())) - }) - }); - if let Some((worktree, file, detail)) = target { - let line = - crate::activity::chronology::resolve_line_in_file(&file, &detail); - let editor = app.store.get_setting("editor_cmd").ok().flatten(); - if let Err(e) = crate::commands::external::open_in_editor_at( - &worktree, - &file, - line, - editor.as_deref(), - ) { - tracing::warn!(error = %e, "failed to open editor from chronology click"); - } - } - } else { - app.chronology_expanded = Some(idx); - } + // Header click → focus the bar, select + expand this entry. + app.chronology_focused = true; + app.chronology_sel = crate::ui::chronology_nav::ChronoSel::Entry(idx); + app.chronology_expanded = Some(idx); } else if let Some(idx) = app.chip_rects.iter().position(|r| { m.column >= r.x && m.column < r.x.saturating_add(r.width) From e9f096092b4431a2786a97cd94089f9983f4b8a3 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 17:12:22 -0400 Subject: [PATCH 35/69] docs: document chronology keyboard navigation and updated mouse model --- README.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 12fcf0e..a86a0ef 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,16 @@ Keystrokes are forwarded to the running `claude` session, except: | `Ctrl-x x` | Send a literal `Ctrl-x` to claude | | `Ctrl-x c` | Toggle the change chronology bar on/off | | `Ctrl-x C` | Swap the chronology bar's side (left ↔ right) | +| `Ctrl-x →` (bar on right) / `Ctrl-x ←` (bar on left) | Move keyboard focus into the chronology bar (from the adjacent edge pane only) | +| `Ctrl-x ←` (bar on right) / `Ctrl-x →` (bar on left) | Return focus from the bar to the agent pane | +| `↑` / `k` *(bar focused)* | Move selection up (toward newer entries) | +| `↓` / `j` *(bar focused)* | Move selection down (toward older entries) | +| `g` *(bar focused)* | Jump to the top (newest entry) | +| `G` *(bar focused)* | Jump to the bottom (oldest entry) | +| `Enter` *(bar focused)* | Expand the selected entry's diff peek (or, when inside a diff peek, open the file in your editor at the changed line) | +| `↓` *(entry expanded)* | Move focus into the diff peek detail | +| `↑` *(inside diff peek)* | Return focus to the entry | +| `Esc` *(bar focused)* | Return focus to the agent pane | When a workspace has more than one agent, the footer also binds bare keys `q w r y i o p s h j` (no leader) to switch the focused pane between agents — see [Multi-agent workspaces](#multi-agent-workspaces). @@ -601,10 +611,20 @@ The rename only fires on workspaces whose name still matches the generated ` Date: Fri, 5 Jun 2026 17:18:59 -0400 Subject: [PATCH 36/69] fix(chronology): drop keyboard focus when bar is hidden; doc Enter toggle --- README.md | 4 ++-- src/app/render.rs | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a86a0ef..e549fec 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Keystrokes are forwarded to the running `claude` session, except: | `↓` / `j` *(bar focused)* | Move selection down (toward older entries) | | `g` *(bar focused)* | Jump to the top (newest entry) | | `G` *(bar focused)* | Jump to the bottom (oldest entry) | -| `Enter` *(bar focused)* | Expand the selected entry's diff peek (or, when inside a diff peek, open the file in your editor at the changed line) | +| `Enter` *(bar focused)* | Toggle expand/collapse the selected entry's diff peek (press again to collapse; or, when inside a diff peek, open the file in your editor at the changed line) | | `↓` *(entry expanded)* | Move focus into the diff peek detail | | `↑` *(inside diff peek)* | Return focus to the entry | | `Esc` *(bar focused)* | Return focus to the agent pane | @@ -622,7 +622,7 @@ The chronology bar is a focusable pane. While attached, press `Ctrl-x` then an a While the bar is focused, keystrokes are captured by the bar and do **not** reach the agent: - `↑` / `k` and `↓` / `j` move the selection; `g` jumps to the top (newest), `G` to the bottom. -- `Enter` on an entry expands its diff peek inline. Press `↓` to move into the diff peek detail, then `Enter` again to open the file in your editor at the changed line. `↑` from the detail returns to the entry. +- `Enter` on an entry expands its diff peek inline (press again to collapse). Press `↓` to move into the diff peek detail, then `Enter` again to open the file in your editor at the changed line. `↑` from the detail returns to the entry. - `Esc` (or `Ctrl-x` + arrow **away** from the bar's side) returns focus to the agent pane. #### Keybindings (attached view, under the `Ctrl-x` leader) diff --git a/src/app/render.rs b/src/app/render.rs index afaf87e..104048b 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -584,6 +584,12 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { }; let (agent_pane_area, bar_rect) = attached::split_for_chronology(pane_area, &chronology_draw); + // If the bar isn't shown this frame (auto-hidden when the terminal + // is too narrow, or toggled off), drop keyboard focus so keystrokes + // flow back to the agent instead of being swallowed by an invisible bar. + if bar_rect.is_none() { + app.chronology_focused = false; + } let crate::ui::split::LayoutResult { panes, dividers } = state.layout(agent_pane_area); let multi_pane = panes.len() > 1; From 326e500b7fa4006d03097b776368bd37216375f4 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Fri, 5 Jun 2026 17:19:42 -0400 Subject: [PATCH 37/69] docs: record index-based selection identity follow-up from final review --- .../plans/2026-06-05-chronology-keyboard-navigation.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/superpowers/plans/2026-06-05-chronology-keyboard-navigation.md b/docs/superpowers/plans/2026-06-05-chronology-keyboard-navigation.md index bcab60c..6e57958 100644 --- a/docs/superpowers/plans/2026-06-05-chronology-keyboard-navigation.md +++ b/docs/superpowers/plans/2026-06-05-chronology-keyboard-navigation.md @@ -872,3 +872,9 @@ git commit -m "docs: document chronology keyboard navigation and mouse model" **Placeholder scan:** No TBDs. Where exact local/variable names in `render.rs`/`input.rs` can't be reproduced verbatim, the step shows the full code and instructs to match the actual surrounding names — the code to write is concrete, not deferred. The cross-task compile ordering (Task 2 folds the `attached.rs` `entry_lines` arity bump; Task 3 folds the `render.rs` `ChronologyDraw` field defaults) is called out explicitly so the tree always compiles. **Type consistency:** `ChronoSel` (`Entry`/`Detail`, `.index()`), `NavKey`, `NavAction` (`None`/`Expand`/`Collapse`/`Open`/`Exit`), `nav`, `adjust_scroll`, `EntryHighlight` (`None`/`Header`/`Detail`), `entry_lines(.., EntryHighlight)`, `ChronologyDraw { .., focused, sel }`, `ChronologyHits { entries, detail, visible_entries }`, `PanesDrawOutput { .., chronology_detail_rect, chronology_visible_entries }`, `App { .., chronology_focused, chronology_sel, chronology_detail_rect, chronology_visible_entries }` — names are consistent across tasks. + +## Known follow-up (from final review) + +**Index-based selection identity (pre-existing, surfaced by keyboard nav).** `chronology_expanded` and `chronology_sel` are stored as bare `usize` indices into the newest-first `Timeline::events()`. When the agent makes a *new* change, the merged timeline re-sorts and inserts the new event at index 0, shifting every existing index by one — so a held selection/expansion silently retargets to a neighbouring change while the user is navigating. This is bounds-safe (`nav` clamps, the renderer skips out-of-range) and was already present for the mouse-driven `chronology_expanded`; the keyboard feature only makes it more noticeable. A proper fix gives each `ChangeEvent` a stable identity (e.g. `(timestamp_ms, file_path, tool, nth-in-line)`) and keys `expanded`/`sel` on that identity instead of a positional index, reconciling the index each frame. Deferred as its own change — out of scope for the keyboard-nav spec. + +The final review also noted two accepted non-issues: keyboard `Enter` toggles expand/collapse while a mouse header-click only expands (documented), and wheel-scrolling a keyboard-focused bar is partly undone by auto-scroll (cosmetic; wheel users aren't typically keyboard-focused). The stuck-focus-when-hidden dead-end was fixed (render drops `chronology_focused` when the bar isn't shown). From 6566a11fb148fd67e58a4b301400be775e2c3798 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 09:27:37 -0400 Subject: [PATCH 38/69] feat(chronology): abbreviate long paths; drop the summary line --- README.md | 2 +- src/ui/chronology_bar.rs | 112 ++++++++++++++++++++++++++++++++++----- 2 files changed, 100 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e549fec..b874c5e 100644 --- a/README.md +++ b/README.md @@ -611,7 +611,7 @@ The rename only fires on workspaces whose name still matches the generated ` bool { area_cols.saturating_sub(bar_cols) < MIN_AGENT_COLS } +/// Front-truncate `s` to `max` columns with a leading `…` so the tail (the +/// filename) stays visible. Counts characters, not bytes. +fn ellipsize_start(s: &str, max: usize) -> String { + let n = s.chars().count(); + if n <= max { + return s.to_string(); + } + if max == 0 { + return String::new(); + } + let tail: String = s.chars().skip(n - (max - 1)).collect(); + format!("…{tail}") +} + +/// Fit a worktree-relative path into `max` columns. If it already fits, return +/// it unchanged. Otherwise abbreviate each ancestor directory (everything +/// before the parent directory) to its first character, keeping the parent +/// directory and filename intact (e.g. `docs/superpowers/specs/foo.md` → +/// `d/s/specs/foo.md`). If still too wide, front-truncate with `…`. +fn abbreviate_path(rel: &str, max: usize) -> String { + if rel.chars().count() <= max { + return rel.to_string(); + } + let parts: Vec<&str> = rel.split('/').collect(); + if parts.len() > 2 { + let last = parts.len() - 1; + let mut out = String::new(); + for (i, p) in parts.iter().enumerate() { + if i > 0 { + out.push('/'); + } + // Ancestors (everything before the parent dir) collapse to their + // first character; the parent dir and filename are kept whole. + if i + 2 <= last { + if let Some(c) = p.chars().next() { + out.push(c); + } + } else { + out.push_str(p); + } + } + if out.chars().count() <= max { + return out; + } + return ellipsize_start(&out, max); + } + ellipsize_start(rel, max) +} + fn hhmm(timestamp_ms: i64) -> String { // Wall-clock HH:MM (UTC) derived from epoch ms without pulling in chrono — // a relative glance, not a precise local timestamp. Matches the @@ -43,8 +92,9 @@ pub enum EntryHighlight { Detail, } -/// Render one entry into lines. Line 1: `HH:MM file`. Line 2: dim summary. -/// When `expanded`, appends up to a few diff-peek lines from `detail`. +/// Render one entry into lines. Line 1: `HH:MM `, where the path is +/// abbreviated (ancestor dirs collapsed to their first letter) to fit the +/// width. When `expanded`, appends up to a few diff-peek lines from `detail`. pub fn entry_lines( ev: &ChangeEvent, worktree: &Path, @@ -54,18 +104,18 @@ pub fn entry_lines( ) -> Vec> { let mut out = Vec::new(); let rel = relative_display(&ev.file_path, worktree); + // The header is `HH:MM ` (6 cols) followed by the path; budget the path to + // the remaining width so long paths abbreviate instead of overflowing. + let path_budget = (width as usize).saturating_sub(6); + let path = abbreviate_path(&rel, path_budget); out.push(Line::from(vec![ Span::styled( hhmm(ev.timestamp_ms), Style::default().add_modifier(Modifier::DIM), ), Span::raw(" "), - Span::raw(rel), + Span::raw(path), ])); - out.push(Line::from(Span::styled( - ev.summary.clone(), - Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC), - ))); if expanded { let peek: Vec = match &ev.detail { ChangeDetail::Edit { old, new } => { @@ -101,8 +151,8 @@ pub fn entry_lines( } } EntryHighlight::Detail => { - // peek lines are everything after the header (0) and summary (1) - for line in out.iter_mut().skip(2) { + // peek lines are everything after the header (index 0) + for line in out.iter_mut().skip(1) { for s in &mut line.spans { s.style = s.style.add_modifier(Modifier::REVERSED); } @@ -150,7 +200,7 @@ mod tests { } #[test] - fn entry_produces_header_and_summary_lines() { + fn collapsed_entry_is_a_single_header_line() { let lines = entry_lines( &ev("/wt/src/main.rs", "fn foo()"), Path::new("/wt"), @@ -158,7 +208,11 @@ mod tests { 40, EntryHighlight::None, ); - assert_eq!(lines.len(), 2, "B fidelity: header + summary, no diff peek"); + assert_eq!( + lines.len(), + 1, + "collapsed: just the time+path header (no summary)" + ); } #[test] @@ -170,7 +224,39 @@ mod tests { 40, EntryHighlight::None, ); - assert!(lines.len() > 2, "expanded entry includes diff peek"); + assert!( + lines.len() > 1, + "expanded entry is the header plus diff-peek lines" + ); + } + + #[test] + fn abbreviate_keeps_short_paths_whole() { + assert_eq!(abbreviate_path("src/main.rs", 40), "src/main.rs"); + } + + #[test] + fn abbreviate_collapses_ancestors_keeping_parent_and_file() { + // 32 cols doesn't fit in 30 → ancestors (src, ui) collapse to first + // char; the parent dir (widgets) and filename are kept whole. + let out = abbreviate_path("src/ui/widgets/chronology_bar.rs", 30); + assert_eq!(out, "s/u/widgets/chronology_bar.rs"); + } + + #[test] + fn abbreviate_front_truncates_when_still_too_long() { + let out = abbreviate_path("docs/superpowers/specs/2026-06-05-foo.md", 15); + assert!(out.chars().count() <= 15, "fits within max"); + assert!(out.starts_with('…'), "front-truncated"); + assert!(out.ends_with("foo.md"), "filename tail preserved"); + } + + #[test] + fn abbreviate_parent_and_file_only_front_truncates() { + // No ancestors to collapse → falls back to front-truncation. + let out = abbreviate_path("widgets/chronology_bar.rs", 12); + assert!(out.chars().count() <= 12); + assert!(out.ends_with(".rs")); } #[test] @@ -205,7 +291,7 @@ mod tests { .any(|s| s.style.add_modifier.contains(Modifier::REVERSED)), "header must NOT be highlighted in Detail mode" ); - let peek_rev = lines.iter().skip(2).any(|l| { + let peek_rev = lines.iter().skip(1).any(|l| { l.spans .iter() .any(|s| s.style.add_modifier.contains(Modifier::REVERSED)) From c119e026fac1276a35a326b985a6d54d5f6800e0 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 12:43:35 -0400 Subject: [PATCH 39/69] docs: design spec for config-driven chronology editor open --- ...026-06-06-chronology-editor-open-design.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-06-chronology-editor-open-design.md diff --git a/docs/superpowers/specs/2026-06-06-chronology-editor-open-design.md b/docs/superpowers/specs/2026-06-06-chronology-editor-open-design.md new file mode 100644 index 0000000..b8c1492 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-chronology-editor-open-design.md @@ -0,0 +1,95 @@ +# Chronology Editor Open (config-driven) — Design + +**Date:** 2026-06-06 +**Status:** Approved for planning +**Builds on:** the Change Chronology bar + keyboard navigation (open-at-line action). + +## Problem + +The chronology "open this change in my editor at the modified line" action silently does nothing for the common case. Diagnosis: `editor_cmd` is unset, so the open path falls back to `$EDITOR` (here `nvim` — a terminal editor) and spawns it **detached** with null stdio. Inside wsx's full-screen TUI (which owns the controlling terminal), a terminal editor has no tty to draw on, so it exits immediately and nothing appears. The failure is swallowed (`tracing::warn!`), so it reads as "nothing happens." + +A secondary defect: even with a windowing wrapper configured (e.g. `alacritty -e nvim`), the line number is dropped, because the arg-builder only inspects the **first** command token to detect the editor — it sees `alacritty`, not `nvim`. + +## Goal + +Make the chronology open-at-line behave predictably and put editor behavior in the user's hands (consistent with wsx's design ethos), without wsx taking over the terminal: + +- **No `editor_cmd` configured →** don't launch; show a dismissible warning telling the user to configure it (with an example). No silent `$EDITOR` fallback for this path. +- **`editor_cmd` configured →** evaluate it and inject the file + line at runtime, then execute it (detached, as today). +- A single `editor_cmd` keeps working for both the dir-open (`e` / `Ctrl-x e`) and this open-at-line path — no separate setting. +- Every previously-silent failure becomes visible. + +## Scope + +- **In scope:** the chronology open-at-line action only — keyboard `Enter` on a selected detail, and mouse click on an expanded detail. +- **Out of scope:** `e` (dashboard) and `Ctrl-x e` (attached) "open workspace in editor" keep their current behavior, including their `$VISUAL`/`$EDITOR` fallback. (Decided during brainstorming.) +- **Rejected alternative:** "suspend-and-run" (wsx leaves raw mode / alt screen, runs a terminal editor with inherited stdio, then redraws). Heavier (touches the event loop + terminal-mode guard) and against wsx's delegate-to-the-user philosophy. Recorded for posterity; not pursued. + +## Behavior + +When the user triggers open-at-line on entry `i`: + +1. Resolve the focused workspace's worktree and the `ChangeEvent` at `i` (file path + detail), cloning owned values. +2. Read the **`editor_cmd`** setting via `store.get_setting("editor_cmd")` — do **not** consult `$VISUAL`/`$EDITOR` for this path. +3. **Unset or whitespace-only →** set `app.modal = Some(Modal::Error { message })` and return. Message: + + > No `editor_cmd` configured. Set one to open changes in your editor, e.g. + > `wsx config set editor_cmd 'alacritty -e nvim'` + +4. **Set →** compute the changed line via `resolve_line_in_file(file, detail)`, build argv via the upgraded `resolve_editor_at_argv`, and spawn detached via the existing `open_in_editor_at` path. On `Err`, set `app.modal = Some(Modal::Error { message: format!("Failed to open editor: {e}") })`. + +The `Modal::Error` block renders after the view match (`src/app/render.rs`), so it is visible over the **attached** view and is dismissible by the existing `Modal::Error` input handling. + +## Runtime file+line injection (`resolve_editor_at_argv` upgrade) + +`resolve_editor_at_argv(cmd: &str, file: &str, line: u32) -> Result>` resolves in this order: + +1. **Placeholders:** if any token contains `{file}` or `{line}`, substitute both across all tokens and return. (Unchanged; explicit escape hatch for editors not in the known set.) +2. **Scan for a known editor:** find the first token whose file-stem basename matches a known editor, and append that editor's goto syntax to the **end** of the argv: + - `code` | `codium` | `cursor` | `zed` → append `--goto` then `{file}:{line}` + - `vim` | `nvim` | `vi` | `nano` | `emacs` | `emacsclient` → append `+{line}` then `{file}` +3. **Fallback:** no known editor token found → append `{file}` only (line dropped). The user can add `{file}`/`{line}` placeholders for an unrecognized editor. + +Appending at the end is correct for terminal wrappers because they pass trailing args to the inner command: `alacritty -e nvim` + `+42 /path` → `alacritty -e nvim +42 /path` (nvim opens at line 42 in a new window); `wezterm start -- code` + `--goto /path:42` → runs `code --goto /path:42`. A bare editor (`nvim`, `code`) is just the degenerate single-token case and keeps working. + +The change from today: scan **all** tokens (today only the first token / program is inspected), so window-wrapper commands detect the inner editor and preserve the line. + +## Components / files + +- **`src/commands/external.rs`** + - Extract `fn known_editor_goto(basename: &str) -> Option` where `enum GotoStyle { Goto, PlusLine }` (or equivalent) encoding the two append shapes. + - Rewrite `resolve_editor_at_argv` to: (a) honor `{file}`/`{line}` placeholders; (b) else scan tokens for the first `known_editor_goto` match and append accordingly; (c) else append the file. + - `open_in_editor_at` is unchanged (still detached). It is only called with a non-empty configured command, so its internal `resolve_editor_cmd(Some(cmd))` returns `cmd` without `$EDITOR` fallback. + - Add a small pure decision helper for the call site: + `pub fn editor_open_decision(editor_cmd: Option<&str>) -> EditorOpenDecision` returning `Launch(String)` for a non-empty trimmed command or `NeedsConfig` otherwise. + +- **`src/app/input.rs`** + - Factor the two chronology open sites (keyboard `NavAction::Open(i)` and mouse expanded-detail click) into one helper, e.g. `fn open_focused_change(app: &mut App, idx: usize)`, that: resolves focused workspace + event (cloning owned `worktree`/`file`/`detail`), reads `editor_cmd`, matches `editor_open_decision`: + - `NeedsConfig` → `app.modal = Some(Modal::Error { message: })`. + - `Launch(cmd)` → `resolve_line_in_file` + `open_in_editor_at(&worktree, &file, line, Some(&cmd))`; on `Err` → `app.modal = Some(Modal::Error { message: })`. + - Both open sites call this helper (removes today's duplicated open block and the silent `tracing::warn!`). + +## Error handling / edge cases + +- Unset/whitespace `editor_cmd` → visible `Modal::Error` with an example (not a silent no-op). +- Spawn failure (bad command, missing binary) → visible `Modal::Error` with the error. +- File deleted/renamed since the edit → `resolve_line_in_file` already returns line 1; the launch proceeds with line 1. +- Unrecognized editor with no placeholders → opens the file without a line (documented); not an error. +- `Modal::Error` set from the attached view renders (render.rs modal block is after the view match) and is dismissible (existing handler). + +## Testing + +- **`resolve_editor_at_argv`** (pure, table-driven): + - placeholder override: `alacritty -e nvim +{line} {file}` → `[alacritty, -e, nvim, +9, /f]`. + - wrapper + terminal editor: `alacritty -e nvim` + (f, 42) → `[alacritty, -e, nvim, +42, /f]`. + - wrapper + GUI editor: `wezterm start -- code` + (f, 7) → `[wezterm, start, --, code, --goto, /f:7]`. + - bare editors still work: `nvim` → `[nvim, +42, /f]`; `code` → `[code, --goto, /f:42]`. + - unknown editor: `foo` → `[foo, /f]` (line dropped). +- **`editor_open_decision`** (pure): `None`/`Some("")`/`Some(" ")` → `NeedsConfig`; `Some("nvim")` → `Launch("nvim")`. +- The input-level modal glue is thin over the two tested helpers; verified by build + manual (open with no config → warning modal; configure `alacritty -e nvim` → opens at line). + +## Files touched + +- `src/commands/external.rs` — `known_editor_goto`, `GotoStyle`, `resolve_editor_at_argv` rewrite, `editor_open_decision` + tests. +- `src/app/input.rs` — `open_focused_change` helper; keyboard + mouse open sites call it; remove silent warn. +- `README.md` — document that the chronology open requires `editor_cmd`, how file+line is injected (placeholders or auto-detected editor), and the `alacritty -e nvim` example. From 1ffb27584fb7895c9f70acd9850bd2f07a4613c2 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 12:48:45 -0400 Subject: [PATCH 40/69] docs: implementation plan for config-driven chronology editor open --- .../2026-06-06-chronology-editor-open.md | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-06-chronology-editor-open.md diff --git a/docs/superpowers/plans/2026-06-06-chronology-editor-open.md b/docs/superpowers/plans/2026-06-06-chronology-editor-open.md new file mode 100644 index 0000000..a16295f --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-chronology-editor-open.md @@ -0,0 +1,354 @@ +# Chronology Editor Open (config-driven) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the chronology "open change at line" action require a configured `editor_cmd` (no silent `$EDITOR` fallback), inject the file+line by detecting the editor anywhere in the command (so window-wrapper commands keep the line), and surface every failure as a visible modal. + +**Architecture:** Two pure helpers in `src/commands/external.rs` — an upgraded `resolve_editor_at_argv` that scans all command tokens for a known editor (not just the first), and a small `editor_open_decision` that gates launch on a non-empty `editor_cmd`. A single `open_focused_change` helper in `src/app/input.rs` consumes both, replacing the two duplicated open sites and their silent `tracing::warn!` with `Modal::Error` surfacing. + +**Tech Stack:** Rust, `shlex` (command parsing), `ratatui`/`crossterm` (the modal is existing UI). Tests are `#[cfg(test)]` unit tests via `cargo test`. + +**Builds on (verified current code):** +- `src/commands/external.rs`: `fn resolve_editor_at_argv(cmd: &str, file: &str, line: u32) -> Result>` currently inspects only the FIRST token's basename for the goto fallback (`code`/`codium`/`cursor` → `--goto file:line`; `vim`/`nvim`/`vi`/`emacs`/`emacsclient` → `+line file`; else append file); `pub fn open_in_editor_at(worktree, file, line, configured) -> Result<()>` resolves via `resolve_editor_cmd(configured)` (which falls back to `$VISUAL`/`$EDITOR` only when `configured` is `None`/empty) and spawns detached. `Error`/`Result` are `crate::error::{Error, Result}`. +- `src/app/input.rs`: the keyboard `NavAction::Open(i)` arm and the mouse expanded-detail-click branch each inline the same block: resolve focused workspace + event (clone `worktree`/`file_path`/`detail`), `resolve_line_in_file`, read `editor_cmd` via `app.store.get_setting("editor_cmd").ok().flatten()`, call `open_in_editor_at(&worktree, &file, line, editor.as_deref())`, and on `Err` only `tracing::warn!`. `focused_attached_workspace(app) -> Option<(WorkspaceId, PathBuf)>` exists. `Modal` is already imported (used for `Modal::Error` elsewhere in the file). +- `src/ui/modal.rs`: `Modal::Error { message: String }` exists and renders after the view match (so it shows over the attached view) and is dismissible by the existing input handler. + +--- + +## File Structure + +- `src/commands/external.rs` (modify) — `GotoStyle` enum, `known_editor_goto`, upgraded `resolve_editor_at_argv`, `EditorOpenDecision` enum, `editor_open_decision` + tests. +- `src/app/input.rs` (modify) — `open_focused_change` helper; keyboard + mouse open sites call it; remove the duplicated blocks and silent warns. +- `README.md` (modify) — document the `editor_cmd` requirement and file+line injection for the chronology open. + +--- + +## Task 1: Scan all tokens for the editor in `resolve_editor_at_argv` + +**Files:** +- Modify: `src/commands/external.rs` + +- [ ] **Step 1: Write the failing tests** + +Add to the `tests` module in `src/commands/external.rs` (alongside the existing `editor_at_*` tests): + +```rust +#[test] +fn editor_at_wrapper_terminal_editor_keeps_line() { + // window-wrapper: the inner editor (nvim) must be detected, not alacritty + let argv = resolve_editor_at_argv("alacritty -e nvim", "/wt/a.rs", 42).unwrap(); + assert_eq!(argv, vec!["alacritty", "-e", "nvim", "+42", "/wt/a.rs"]); +} + +#[test] +fn editor_at_wrapper_gui_editor_uses_goto() { + let argv = resolve_editor_at_argv("wezterm start -- code", "/wt/a.rs", 7).unwrap(); + assert_eq!(argv, vec!["wezterm", "start", "--", "code", "--goto", "/wt/a.rs:7"]); +} + +#[test] +fn editor_at_zed_uses_goto() { + let argv = resolve_editor_at_argv("zed", "/wt/a.rs", 5).unwrap(); + assert_eq!(argv, vec!["zed", "--goto", "/wt/a.rs:5"]); +} + +#[test] +fn editor_at_nano_uses_plus_line() { + let argv = resolve_editor_at_argv("nano", "/wt/a.rs", 5).unwrap(); + assert_eq!(argv, vec!["nano", "+5", "/wt/a.rs"]); +} +``` + +(The existing tests — `editor_at_substitutes_file_and_line_placeholders`, `editor_at_vim_fallback_uses_plus_line`, `editor_at_code_fallback_uses_goto`, `editor_at_emacs_fallback_uses_plus_line`, `editor_at_unknown_editor_appends_file_only`, `editor_at_substitutes_placeholders_in_separate_tokens` — must continue to pass unchanged: they are the bare-editor / placeholder / unknown cases the rewrite still handles.) + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib editor_at_wrapper` +Expected: FAIL — `alacritty -e nvim` currently appends only the file (first-token `alacritty` isn't a known editor), so the line `+42` is missing. + +- [ ] **Step 3: Rewrite `resolve_editor_at_argv` to scan all tokens** + +Replace the existing `resolve_editor_at_argv` in `src/commands/external.rs` with: + +```rust +/// How an editor wants a file+line on its command line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GotoStyle { + /// VS Code family: `--goto file:line`. + Goto, + /// vi/emacs family: `+line file`. + PlusLine, +} + +/// Map an editor program basename to its goto style, if known. +fn known_editor_goto(basename: &str) -> Option { + match basename { + "code" | "codium" | "cursor" | "zed" => Some(GotoStyle::Goto), + "vim" | "nvim" | "vi" | "nano" | "emacs" | "emacsclient" => Some(GotoStyle::PlusLine), + _ => None, + } +} + +/// Resolve the editor command into argv that opens `file` at `line`. +/// +/// Resolution order: +/// 1. If the command contains `{file}`/`{line}` placeholders, substitute them. +/// 2. Else scan ALL tokens for the first one whose basename is a known editor +/// and append that editor's goto syntax (so window-wrapper commands like +/// `alacritty -e nvim` detect the inner editor and keep the line). +/// 3. Else append the file (line dropped); the user can add `{file}`/`{line}` +/// placeholders for an unrecognized editor. +fn resolve_editor_at_argv(cmd: &str, file: &str, line: u32) -> Result> { + let line_s = line.to_string(); + let mut parts = shlex::split(cmd) + .ok_or_else(|| Error::UserInput(format!("could not parse command: {cmd}")))?; + if parts.is_empty() { + return Err(Error::UserInput("command is empty".into())); + } + let used_placeholder = parts + .iter() + .any(|p| p.contains("{file}") || p.contains("{line}")); + if used_placeholder { + for part in &mut parts { + *part = part.replace("{file}", file).replace("{line}", &line_s); + } + return Ok(parts); + } + let style = parts.iter().find_map(|p| { + let base = std::path::Path::new(p) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(p); + known_editor_goto(base) + }); + match style { + Some(GotoStyle::Goto) => { + parts.push("--goto".to_string()); + parts.push(format!("{file}:{line_s}")); + } + Some(GotoStyle::PlusLine) => { + parts.push(format!("+{line_s}")); + parts.push(file.to_string()); + } + None => parts.push(file.to_string()), + } + Ok(parts) +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib editor_at_` +Expected: PASS — the 4 new tests plus all pre-existing `editor_at_*` tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/external.rs +git commit -m "feat(editor): scan all tokens for the editor so wrappers keep the line" +``` + +--- + +## Task 2: `editor_open_decision` — require a configured editor + +**Files:** +- Modify: `src/commands/external.rs` + +- [ ] **Step 1: Write the failing tests** + +Add to the `tests` module in `src/commands/external.rs`: + +```rust +#[test] +fn editor_decision_needs_config_when_unset_or_blank() { + assert_eq!(editor_open_decision(None), EditorOpenDecision::NeedsConfig); + assert_eq!(editor_open_decision(Some("")), EditorOpenDecision::NeedsConfig); + assert_eq!(editor_open_decision(Some(" ")), EditorOpenDecision::NeedsConfig); +} + +#[test] +fn editor_decision_launches_trimmed_command() { + assert_eq!( + editor_open_decision(Some(" alacritty -e nvim ")), + EditorOpenDecision::Launch("alacritty -e nvim".to_string()) + ); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib editor_decision` +Expected: FAIL — `editor_open_decision` / `EditorOpenDecision` not found. + +- [ ] **Step 3: Implement** + +Add to `src/commands/external.rs` (non-test scope): + +```rust +/// Outcome of deciding whether the chronology open-at-line can launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EditorOpenDecision { + /// Launch the trimmed command. + Launch(String), + /// No usable `editor_cmd`; the caller should prompt the user to configure one. + NeedsConfig, +} + +/// Decide whether the chronology open-at-line can launch. A non-empty, +/// non-whitespace `editor_cmd` yields `Launch`; anything else `NeedsConfig`. +/// Unlike `open_in_editor`, this path does NOT fall back to `$VISUAL`/`$EDITOR` +/// — opening a file at a line needs an editor the user has chosen to wire up. +pub fn editor_open_decision(editor_cmd: Option<&str>) -> EditorOpenDecision { + match editor_cmd { + Some(c) if !c.trim().is_empty() => EditorOpenDecision::Launch(c.trim().to_string()), + _ => EditorOpenDecision::NeedsConfig, + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib editor_decision` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/commands/external.rs +git commit -m "feat(editor): editor_open_decision gates open-at-line on configured editor_cmd" +``` + +--- + +## Task 3: `open_focused_change` helper + wire both open sites + +**Files:** +- Modify: `src/app/input.rs` + +This task has no isolated unit test (it sets `Modal::Error` / spawns — side effects); the decision + argv logic it depends on are unit-tested in Tasks 1–2. Verified by build + manual. + +- [ ] **Step 1: Add the helper** + +Add to `src/app/input.rs` (near `focused_attached_workspace`): + +```rust +/// Open the chronology entry at `idx` in the user's configured editor at the +/// changed line. Requires `editor_cmd` (no `$EDITOR` fallback for this path): +/// surfaces a `Modal::Error` when it's unset or when the spawn fails. +fn open_focused_change(app: &mut App, idx: usize) { + use crate::commands::external::{EditorOpenDecision, editor_open_decision}; + // Clone the path + detail out of the chronology borrow before touching + // app.store / app.modal. + let Some((worktree, file, detail)) = + focused_attached_workspace(app).and_then(|(ws_id, worktree)| { + app.chronology.get(&ws_id).and_then(|t| { + t.events() + .get(idx) + .map(|ev| (worktree, ev.file_path.clone(), ev.detail.clone())) + }) + }) + else { + return; + }; + let editor_cmd = app.store.get_setting("editor_cmd").ok().flatten(); + match editor_open_decision(editor_cmd.as_deref()) { + EditorOpenDecision::NeedsConfig => { + app.modal = Some(crate::ui::modal::Modal::Error { + message: "No editor_cmd configured. Set one to open changes in your \ + editor, e.g.\n wsx config set editor_cmd 'alacritty -e nvim'" + .to_string(), + }); + } + EditorOpenDecision::Launch(cmd) => { + let line = crate::activity::chronology::resolve_line_in_file(&file, &detail); + if let Err(e) = + crate::commands::external::open_in_editor_at(&worktree, &file, line, Some(&cmd)) + { + app.modal = Some(crate::ui::modal::Modal::Error { + message: format!("Failed to open editor: {e}"), + }); + } + } + } +} +``` + +- [ ] **Step 2: Wire the keyboard open site** + +In the chronology key-interception block, replace the entire `NavAction::Open(i) => { ... }` arm body (the block that currently clones the event, calls `open_in_editor_at`, and `tracing::warn!`s on error) with a single call: + +```rust + NavAction::Open(i) => open_focused_change(app, i), +``` + +- [ ] **Step 3: Wire the mouse open site** + +In `handle_mouse`, the chronology detail-click branch currently inlines the same open block then sets focus/sel. Replace its inline open block (the `let target = ...; if let Some((worktree, file, detail)) = target { ... tracing::warn! ... }`) with a call to the helper, keeping the focus/sel lines: + +```rust + }) { + open_focused_change(app, idx); + app.chronology_focused = true; + app.chronology_sel = crate::ui::chronology_nav::ChronoSel::Detail(idx); + } else if let Some(idx) = app.chronology_entry_rects.iter().find_map(|(i, r)| { + // ... existing header-click branch, unchanged ... +``` + +Read the actual branch and replace only the open block; preserve the `app.chronology_focused = true;` / `app.chronology_sel = ChronoSel::Detail(idx);` lines and the rest of the click chain. + +- [ ] **Step 4: Build + manual verify** + +Run: `cargo build` — ZERO warnings (the old `tracing::warn!`-only blocks are gone; confirm no now-unused imports remain — if `tracing` is still used elsewhere in the file it stays, otherwise remove the unused import). +Run: `cargo test --lib` — no regressions (~1118). +Manual: in an attached Claude workspace with `editor_cmd` unset, trigger a chronology open (Enter on a detail, or click the expanded detail) → a dismissible error modal appears with the configure hint. Then `wsx config set editor_cmd 'alacritty -e nvim'` and repeat → nvim opens in a new alacritty window at the change's line. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/input.rs +git commit -m "feat(chronology): require editor_cmd for open; surface failures as a modal" +``` + +--- + +## Task 4: README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Document the editor open behavior** + +In the "Change chronology" section of `README.md` (where opening a change in the editor is described), add/adjust prose to state: +- Opening a change at its line requires a configured `editor_cmd`; if unset, wsx shows a prompt to configure it (it does not fall back to `$EDITOR` for this action). +- wsx injects the file and line into `editor_cmd` at runtime: if the command contains `{file}`/`{line}` placeholders they are substituted; otherwise wsx detects a known editor anywhere in the command (`code`/`codium`/`cursor`/`zed`, or `vim`/`nvim`/`vi`/`nano`/`emacs`/`emacsclient`) and appends the right goto args — so a window wrapper like `alacritty -e nvim` opens the file at the line in its own window. +- Example: `wsx config set editor_cmd 'alacritty -e nvim'`. +- Unrecognized editors: add `{file}`/`{line}` placeholders to `editor_cmd` to control the syntax. + +Match the README's existing prose/code-block style. + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: document chronology editor open (editor_cmd required + file:line injection)" +``` + +--- + +## Self-Review (completed during planning) + +**Spec coverage:** +- No `editor_cmd` → visible warning, no `$EDITOR` fallback → Task 2 (`editor_open_decision`) + Task 3 (`NeedsConfig` → `Modal::Error`). ✓ +- `editor_cmd` set → inject file+line at runtime, execute → Task 1 (`resolve_editor_at_argv` scan/append) + Task 3 (`Launch` → `open_in_editor_at(Some(cmd))`). ✓ +- Scan all tokens (vs first-token) so wrappers keep the line → Task 1. ✓ +- Single `editor_cmd` serves both dir-open and at-line (no separate setting) → Task 1 design (append at end), unchanged `e`/`Ctrl-x e`. ✓ +- Surface spawn failures visibly → Task 3 (`Err` → `Modal::Error`). ✓ +- Placeholders remain an escape hatch → Task 1 (placeholder branch first). ✓ +- Scope: chronology open only; `e`/`Ctrl-x e` untouched → Tasks 3 only touches the two chronology open sites. ✓ +- Tests: pure `resolve_editor_at_argv` + `editor_open_decision` → Tasks 1–2. ✓ +- README → Task 4. ✓ + +**Placeholder scan:** No "TBD"/vague steps; every code step shows complete code. Task 3's manual-verify is explicit. The only non-code instruction (Task 3 Step 3 "replace only the open block") names exactly what to preserve. + +**Type consistency:** `GotoStyle` (`Goto`/`PlusLine`), `known_editor_goto`, `resolve_editor_at_argv` (unchanged signature), `EditorOpenDecision` (`Launch(String)`/`NeedsConfig`), `editor_open_decision`, `open_focused_change`, `open_in_editor_at(.., Some(&cmd))`, `Modal::Error { message }`, `ChronoSel::Detail` — names consistent across tasks. From 8165c791e34180504b3a333c583215e977c35bac Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 12:51:15 -0400 Subject: [PATCH 41/69] feat(editor): scan all tokens for the editor so wrappers keep the line Replace resolve_editor_at_argv's first-token-only check with a scan of all argv tokens, so window-wrapper configs like `alacritty -e nvim` or `wezterm start -- code` detect the inner editor and retain the line number. Adds GotoStyle enum + known_editor_goto helper; extends known editors to include zed (Goto) and nano (PlusLine). 4 new tests + 6 pre-existing pass. --- src/commands/external.rs | 77 ++++++++++++++++++++++++++++++++-------- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/src/commands/external.rs b/src/commands/external.rs index 8bc99e6..dd48a66 100644 --- a/src/commands/external.rs +++ b/src/commands/external.rs @@ -206,13 +206,33 @@ fn resolve_argv( Ok(parts) } +/// How an editor wants a file+line on its command line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GotoStyle { + /// VS Code family: `--goto file:line`. + Goto, + /// vi/emacs family: `+line file`. + PlusLine, +} + +/// Map an editor program basename to its goto style, if known. +fn known_editor_goto(basename: &str) -> Option { + match basename { + "code" | "codium" | "cursor" | "zed" => Some(GotoStyle::Goto), + "vim" | "nvim" | "vi" | "nano" | "emacs" | "emacsclient" => Some(GotoStyle::PlusLine), + _ => None, + } +} + /// Resolve the editor command into argv that opens `file` at `line`. /// -/// If the template contains `{file}`/`{line}` placeholders, substitute them. -/// Otherwise fall back to the goto convention of common editors detected from -/// the program name: VS Code (`code --goto file:line`), vim/nvim/vi -/// (`+line file`), emacs/emacsclient (`+line file`); any other editor gets the -/// file appended with the line omitted. +/// Resolution order: +/// 1. If the command contains `{file}`/`{line}` placeholders, substitute them. +/// 2. Else scan ALL tokens for the first one whose basename is a known editor +/// and append that editor's goto syntax (so window-wrapper commands like +/// `alacritty -e nvim` detect the inner editor and keep the line). +/// 3. Else append the file (line dropped); the user can add `{file}`/`{line}` +/// placeholders for an unrecognized editor. fn resolve_editor_at_argv(cmd: &str, file: &str, line: u32) -> Result> { let line_s = line.to_string(); let mut parts = shlex::split(cmd) @@ -229,21 +249,23 @@ fn resolve_editor_at_argv(cmd: &str, file: &str, line: u32) -> Result { + let style = parts.iter().find_map(|p| { + let base = std::path::Path::new(p) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(p); + known_editor_goto(base) + }); + match style { + Some(GotoStyle::Goto) => { parts.push("--goto".to_string()); parts.push(format!("{file}:{line_s}")); } - "vim" | "nvim" | "vi" | "emacs" | "emacsclient" => { + Some(GotoStyle::PlusLine) => { parts.push(format!("+{line_s}")); parts.push(file.to_string()); } - _ => parts.push(file.to_string()), + None => parts.push(file.to_string()), } Ok(parts) } @@ -494,4 +516,31 @@ mod tests { let argv = resolve_editor_at_argv("nvim +{line} {file}", "/tmp/wt/a.rs", 9).unwrap(); assert_eq!(argv, vec!["nvim", "+9", "/tmp/wt/a.rs"]); } + + #[test] + fn editor_at_wrapper_terminal_editor_keeps_line() { + let argv = resolve_editor_at_argv("alacritty -e nvim", "/wt/a.rs", 42).unwrap(); + assert_eq!(argv, vec!["alacritty", "-e", "nvim", "+42", "/wt/a.rs"]); + } + + #[test] + fn editor_at_wrapper_gui_editor_uses_goto() { + let argv = resolve_editor_at_argv("wezterm start -- code", "/wt/a.rs", 7).unwrap(); + assert_eq!( + argv, + vec!["wezterm", "start", "--", "code", "--goto", "/wt/a.rs:7"] + ); + } + + #[test] + fn editor_at_zed_uses_goto() { + let argv = resolve_editor_at_argv("zed", "/wt/a.rs", 5).unwrap(); + assert_eq!(argv, vec!["zed", "--goto", "/wt/a.rs:5"]); + } + + #[test] + fn editor_at_nano_uses_plus_line() { + let argv = resolve_editor_at_argv("nano", "/wt/a.rs", 5).unwrap(); + assert_eq!(argv, vec!["nano", "+5", "/wt/a.rs"]); + } } From 560e5313a349dea5e633114de2d3713ba5d61742 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 12:52:45 -0400 Subject: [PATCH 42/69] feat(editor): editor_open_decision gates open-at-line on configured editor_cmd --- src/commands/external.rs | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/commands/external.rs b/src/commands/external.rs index dd48a66..ddfe6fb 100644 --- a/src/commands/external.rs +++ b/src/commands/external.rs @@ -284,6 +284,26 @@ pub fn open_in_editor_at( spawn_parts(parts, worktree) } +/// Outcome of deciding whether the chronology open-at-line can launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EditorOpenDecision { + /// Launch the trimmed command. + Launch(String), + /// No usable `editor_cmd`; the caller should prompt the user to configure one. + NeedsConfig, +} + +/// Decide whether the chronology open-at-line can launch. A non-empty, +/// non-whitespace `editor_cmd` yields `Launch`; anything else `NeedsConfig`. +/// Unlike `open_in_editor`, this path does NOT fall back to `$VISUAL`/`$EDITOR` +/// — opening a file at a line needs an editor the user has chosen to wire up. +pub fn editor_open_decision(editor_cmd: Option<&str>) -> EditorOpenDecision { + match editor_cmd { + Some(c) if !c.trim().is_empty() => EditorOpenDecision::Launch(c.trim().to_string()), + _ => EditorOpenDecision::NeedsConfig, + } +} + fn detach_io(cmd: &mut std::process::Command) { cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) @@ -543,4 +563,25 @@ mod tests { let argv = resolve_editor_at_argv("nano", "/wt/a.rs", 5).unwrap(); assert_eq!(argv, vec!["nano", "+5", "/wt/a.rs"]); } + + #[test] + fn editor_decision_needs_config_when_unset_or_blank() { + assert_eq!(editor_open_decision(None), EditorOpenDecision::NeedsConfig); + assert_eq!( + editor_open_decision(Some("")), + EditorOpenDecision::NeedsConfig + ); + assert_eq!( + editor_open_decision(Some(" ")), + EditorOpenDecision::NeedsConfig + ); + } + + #[test] + fn editor_decision_launches_trimmed_command() { + assert_eq!( + editor_open_decision(Some(" alacritty -e nvim ")), + EditorOpenDecision::Launch("alacritty -e nvim".to_string()) + ); + } } From 7083c598a8905ffe7a8e4f39650a37a067ff564a Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 12:54:48 -0400 Subject: [PATCH 43/69] feat(chronology): require editor_cmd for open; surface failures as a modal --- src/app/input.rs | 86 +++++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 44 deletions(-) diff --git a/src/app/input.rs b/src/app/input.rs index 69d5a34..479f906 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -288,6 +288,46 @@ fn focused_attached_workspace( Some((ws_id, worktree)) } +/// Open the chronology entry at `idx` in the user's configured editor at the +/// changed line. Requires `editor_cmd` (no `$EDITOR` fallback for this path): +/// surfaces a `Modal::Error` when it's unset or when the spawn fails. +fn open_focused_change(app: &mut App, idx: usize) { + use crate::commands::external::{EditorOpenDecision, editor_open_decision}; + // Clone the path + detail out of the chronology borrow before touching + // app.store / app.modal. + let Some((worktree, file, detail)) = + focused_attached_workspace(app).and_then(|(ws_id, worktree)| { + app.chronology.get(&ws_id).and_then(|t| { + t.events() + .get(idx) + .map(|ev| (worktree, ev.file_path.clone(), ev.detail.clone())) + }) + }) + else { + return; + }; + let editor_cmd = app.store.get_setting("editor_cmd").ok().flatten(); + match editor_open_decision(editor_cmd.as_deref()) { + EditorOpenDecision::NeedsConfig => { + app.modal = Some(crate::ui::modal::Modal::Error { + message: "No editor_cmd configured. Set one to open changes in your \ + editor, e.g.\n wsx config set editor_cmd 'alacritty -e nvim'" + .to_string(), + }); + } + EditorOpenDecision::Launch(cmd) => { + let line = crate::activity::chronology::resolve_line_in_file(&file, &detail); + if let Err(e) = + crate::commands::external::open_in_editor_at(&worktree, &file, line, Some(&cmd)) + { + app.modal = Some(crate::ui::modal::Modal::Error { + message: format!("Failed to open editor: {e}"), + }); + } + } + } +} + /// Resolve the configured chronology side for the focused attached workspace. fn focused_chronology_side(app: &App) -> Option { let crate::ui::View::Attached(state) = &app.view else { @@ -1020,31 +1060,7 @@ async fn handle_key_attached( NavAction::Expand(i) => app.chronology_expanded = Some(i), NavAction::Collapse(_) => app.chronology_expanded = None, NavAction::Exit => app.chronology_focused = false, - NavAction::Open(i) => { - // Clone path+detail out of the chronology borrow before - // touching app.store / spawning the editor. - if let Some((worktree, file, detail)) = focused_attached_workspace(app) - .and_then(|(ws_id, worktree)| { - app.chronology.get(&ws_id).and_then(|t| { - t.events() - .get(i) - .map(|ev| (worktree, ev.file_path.clone(), ev.detail.clone())) - }) - }) - { - let line = - crate::activity::chronology::resolve_line_in_file(&file, &detail); - let editor = app.store.get_setting("editor_cmd").ok().flatten(); - if let Err(e) = crate::commands::external::open_in_editor_at( - &worktree, - &file, - line, - editor.as_deref(), - ) { - tracing::warn!(error = %e, "failed to open editor from chronology keyboard"); - } - } - } + NavAction::Open(i) => open_focused_change(app, i), } } // While focused, swallow ALL keys — the agent PTY must not receive them. @@ -1914,25 +1930,7 @@ async fn handle_mouse(app: &mut App, m: MouseEvent) { && m.row >= r.y && m.row < r.y.saturating_add(r.height) }) { - let target = focused_attached_workspace(app).and_then(|(ws_id, worktree)| { - app.chronology.get(&ws_id).and_then(|t| { - t.events() - .get(idx) - .map(|ev| (worktree, ev.file_path.clone(), ev.detail.clone())) - }) - }); - if let Some((worktree, file, detail)) = target { - let line = crate::activity::chronology::resolve_line_in_file(&file, &detail); - let editor = app.store.get_setting("editor_cmd").ok().flatten(); - if let Err(e) = crate::commands::external::open_in_editor_at( - &worktree, - &file, - line, - editor.as_deref(), - ) { - tracing::warn!(error = %e, "failed to open editor from chronology detail click"); - } - } + open_focused_change(app, idx); app.chronology_focused = true; app.chronology_sel = crate::ui::chronology_nav::ChronoSel::Detail(idx); } else if let Some(idx) = app.chronology_entry_rects.iter().find_map(|(i, r)| { From 4465b8765bc64133b4aceddf38f9ff1b861895fa Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 12:58:02 -0400 Subject: [PATCH 44/69] docs: document chronology editor open (editor_cmd required + file:line injection) --- README.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b874c5e..2bc31ce 100644 --- a/README.md +++ b/README.md @@ -550,7 +550,7 @@ Known keys: | `process_doctrine` | Standing "operating doctrine" injected into every developer session (new and resumed) across all agents: think and plan before scope is set, use the superpowers skills by default (Claude/Pi only), break work into logical commits, and load the wsx skill. Not applied to the Project Manager session. Set this to replace the default text verbatim (`@file` supported); set it to `off` / `none` / `disabled` to suppress injection entirely. A blank value restores the default (it is not an off switch). | | `coding_agent` | Default coding agent for new workspaces _and_ the Project Manager pane: `claude` (default) / `pi` / `hermes` / `codex`. Per-workspace override via `wsx workspace create --agent ` (does not affect the PM). See [Coding agents](#coding-agents). | | `nerd_fonts` | Render nerd-font glyphs in the dashboard. Default ON; set to `false` / `0` / `off` to disable. | -| `editor_cmd` | Command to run for `[e] edit` on the dashboard. Worktree path appended as final arg unless the command contains `{path}` (substituted in place). Examples: `code`, `cursor`, `alacritty -e nvim`, `xdg-terminal-exec --dir={path} nvim`. | +| `editor_cmd` | Command to run for `[e] edit` on the dashboard. Worktree path appended as final arg unless the command contains `{path}` (substituted in place). Examples: `code`, `cursor`, `alacritty -e nvim`, `xdg-terminal-exec --dir={path} nvim`. Also required for the chronology bar's "open at changed line" action; see [Change chronology](#change-chronology) for the `{file}`/`{line}` injection details. | | `terminal_cmd` | Command to run for `[t] terminal` on the dashboard. Spawned with cwd=worktree; `{path}` substituted in place if present. Examples: `alacritty`, `kitty`, `gnome-terminal`. | | `notifications` | Ring the terminal bell and show a `!` marker when a workspace transitions to `waiting` (claude paused for ≥30s). Default ON; set to `off` / `false` / `0` / `no` to disable. | | `theme` | Color theme. One of `default` (palette-adaptive ANSI), `dracula` (RGB), `jellybeans` (RGB), `nord` (RGB). Unknown values fall back to `default`. Restart wsx after changing. | @@ -634,7 +634,32 @@ While the bar is focused, keystrokes are captured by the bar and do **not** reac Mouse wheel over the bar scrolls it. Click an entry to focus the bar, select the entry, and expand its diff peek. Click the expanded diff peek to open your editor at the changed line. -The click-to-open jump requires `editor_cmd` to be configured (see [Editor, terminal, and diff integration](#editor-terminal-and-diff-integration)). If your command contains `{file}` and `{line}` placeholders they are substituted in place. For recognized editors without explicit placeholders, wsx falls back to goto syntax: `code --goto file:line` for VS Code and Cursor; `+line file` for Vim/Neovim/Vi and Emacs/Emacsclient. +#### Opening a file at the changed line + +Both the keyboard path (`Enter` while the diff peek detail is focused) and the mouse path (click the expanded diff peek) open the file in your editor, jumping directly to the modified line. + +**`editor_cmd` is required for this action.** If `editor_cmd` is unset, wsx shows a dismissible prompt telling you to configure it. There is no silent fallback to `$VISUAL` or `$EDITOR` for this specific action — those env-var fallbacks still apply to the separate `[e]` / `Ctrl-x e` "open workspace in editor" actions, which are unchanged. + +**File and line injection.** When `editor_cmd` is set, wsx injects the file path and line number at runtime using one of two strategies: + +- **Placeholders**: if your command contains `{file}` and/or `{line}`, they are substituted in place. Use this for editors wsx doesn't recognize or when you need exact control over argument order. +- **Auto-detection**: if no placeholders are present, wsx scans the command for a known editor name and appends the appropriate goto arguments: + - `code`, `codium`, `cursor`, `zed` → `--goto :` + - `vim`, `nvim`, `vi`, `nano`, `emacs`, `emacsclient` → `+ ` + +Detection matches the editor name **anywhere** in the command, so a terminal wrapper works transparently. For example, `alacritty -e nvim` is detected as nvim and becomes `alacritty -e nvim + `, opening the file at the changed line in a new terminal window. + +```bash +wsx config set editor_cmd 'alacritty -e nvim' +``` + +For an editor wsx doesn't recognize, add `{file}` and `{line}` placeholders to control the exact syntax: + +```bash +wsx config set editor_cmd 'myed --line {line} {file}' +``` + +**Error visibility.** If the editor fails to launch, wsx surfaces the error in a dismissible prompt — failures are no longer silent. #### Schema and defaults From 1db72e1ce1b32286a72bc7df632b2ed6d22ab282 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 13:02:19 -0400 Subject: [PATCH 45/69] test(editor): cover unknown-editor fallback and first-known-editor-wins --- src/commands/external.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/commands/external.rs b/src/commands/external.rs index ddfe6fb..d6b6560 100644 --- a/src/commands/external.rs +++ b/src/commands/external.rs @@ -564,6 +564,21 @@ mod tests { assert_eq!(argv, vec!["nano", "+5", "/wt/a.rs"]); } + #[test] + fn editor_at_unknown_wrapped_editor_appends_file_only() { + // No known editor token and no placeholders → append the file, line dropped. + let argv = resolve_editor_at_argv("myterm -e myed", "/wt/a.rs", 9).unwrap(); + assert_eq!(argv, vec!["myterm", "-e", "myed", "/wt/a.rs"]); + } + + #[test] + fn editor_at_first_known_editor_token_wins() { + // When more than one known editor appears, the first match decides the + // goto style (here `code` → --goto, even though `vim` follows). + let argv = resolve_editor_at_argv("code --diff vim", "/wt/a.rs", 3).unwrap(); + assert_eq!(argv, vec!["code", "--diff", "vim", "--goto", "/wt/a.rs:3"]); + } + #[test] fn editor_decision_needs_config_when_unset_or_blank() { assert_eq!(editor_open_decision(None), EditorOpenDecision::NeedsConfig); From e11a77a5a81b25aa2ba564e504da3b7ac8c9f815 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 14:49:19 -0400 Subject: [PATCH 46/69] fix(editor): substitute {path} in open-at-line so shared editor_cmd works Adds a `path` parameter to `resolve_editor_at_argv` and substitutes `{path}` (the worktree) across all tokens before the `{file}`/`{line}` logic, so a command like `xdg-terminal-exec --dir={path} nvim` works for the chronology bar's open-at-line action as well as the dir-open action. Updates all 12 existing call sites and adds 2 new tests. README updated to document `{path}` substitution for this action. --- README.md | 10 ++++-- src/commands/external.rs | 70 +++++++++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2bc31ce..55c3c63 100644 --- a/README.md +++ b/README.md @@ -642,8 +642,8 @@ Both the keyboard path (`Enter` while the diff peek detail is focused) and the m **File and line injection.** When `editor_cmd` is set, wsx injects the file path and line number at runtime using one of two strategies: -- **Placeholders**: if your command contains `{file}` and/or `{line}`, they are substituted in place. Use this for editors wsx doesn't recognize or when you need exact control over argument order. -- **Auto-detection**: if no placeholders are present, wsx scans the command for a known editor name and appends the appropriate goto arguments: +- **Placeholders**: if your command contains `{file}`, `{line}`, and/or `{path}`, they are substituted in place. `{path}` is the worktree root (the same value substituted by the `[e]` dir-open action), so a single `editor_cmd` works for both actions. Use placeholders for editors wsx doesn't recognize or when you need exact control over argument order. +- **Auto-detection**: if no `{file}` or `{line}` placeholders are present, wsx scans the command for a known editor name and appends the appropriate goto arguments (after substituting any `{path}` first): - `code`, `codium`, `cursor`, `zed` → `--goto :` - `vim`, `nvim`, `vi`, `nano`, `emacs`, `emacsclient` → `+ ` @@ -653,6 +653,12 @@ Detection matches the editor name **anywhere** in the command, so a terminal wra wsx config set editor_cmd 'alacritty -e nvim' ``` +Commands with `{path}` also work — the worktree is substituted first, then the editor is auto-detected or `{file}`/`{line}` are substituted: + +```bash +wsx config set editor_cmd 'xdg-terminal-exec --dir={path} nvim' +``` + For an editor wsx doesn't recognize, add `{file}` and `{line}` placeholders to control the exact syntax: ```bash diff --git a/src/commands/external.rs b/src/commands/external.rs index d6b6560..d04a487 100644 --- a/src/commands/external.rs +++ b/src/commands/external.rs @@ -233,13 +233,18 @@ fn known_editor_goto(basename: &str) -> Option { /// `alacritty -e nvim` detect the inner editor and keep the line). /// 3. Else append the file (line dropped); the user can add `{file}`/`{line}` /// placeholders for an unrecognized editor. -fn resolve_editor_at_argv(cmd: &str, file: &str, line: u32) -> Result> { +fn resolve_editor_at_argv(cmd: &str, path: &str, file: &str, line: u32) -> Result> { let line_s = line.to_string(); let mut parts = shlex::split(cmd) .ok_or_else(|| Error::UserInput(format!("could not parse command: {cmd}")))?; if parts.is_empty() { return Err(Error::UserInput("command is empty".into())); } + // Substitute {path} (the worktree / working dir) first, so an editor_cmd + // shared with the dir-open action — which also uses {path} — works here too. + for part in &mut parts { + *part = part.replace("{path}", path); + } let used_placeholder = parts .iter() .any(|p| p.contains("{file}") || p.contains("{line}")); @@ -279,8 +284,9 @@ pub fn open_in_editor_at( configured: Option<&str>, ) -> Result<()> { let cmd = resolve_editor_cmd(configured)?; + let worktree_str = worktree.to_string_lossy(); let file_str = file.to_string_lossy(); - let parts = resolve_editor_at_argv(&cmd, file_str.as_ref(), line)?; + let parts = resolve_editor_at_argv(&cmd, worktree_str.as_ref(), file_str.as_ref(), line)?; spawn_parts(parts, worktree) } @@ -502,50 +508,56 @@ mod tests { #[test] fn editor_at_substitutes_file_and_line_placeholders() { - let argv = - resolve_editor_at_argv("code --goto {file}:{line}", "/tmp/wt/src/main.rs", 42).unwrap(); + let argv = resolve_editor_at_argv( + "code --goto {file}:{line}", + "/tmp/wt", + "/tmp/wt/src/main.rs", + 42, + ) + .unwrap(); assert_eq!(argv, vec!["code", "--goto", "/tmp/wt/src/main.rs:42"]); } #[test] fn editor_at_vim_fallback_uses_plus_line() { - let argv = resolve_editor_at_argv("nvim", "/tmp/wt/src/main.rs", 42).unwrap(); + let argv = resolve_editor_at_argv("nvim", "/tmp/wt", "/tmp/wt/src/main.rs", 42).unwrap(); assert_eq!(argv, vec!["nvim", "+42", "/tmp/wt/src/main.rs"]); } #[test] fn editor_at_code_fallback_uses_goto() { - let argv = resolve_editor_at_argv("code", "/tmp/wt/src/main.rs", 7).unwrap(); + let argv = resolve_editor_at_argv("code", "/tmp/wt", "/tmp/wt/src/main.rs", 7).unwrap(); assert_eq!(argv, vec!["code", "--goto", "/tmp/wt/src/main.rs:7"]); } #[test] fn editor_at_emacs_fallback_uses_plus_line() { - let argv = resolve_editor_at_argv("emacsclient", "/tmp/wt/a.rs", 3).unwrap(); + let argv = resolve_editor_at_argv("emacsclient", "/tmp/wt", "/tmp/wt/a.rs", 3).unwrap(); assert_eq!(argv, vec!["emacsclient", "+3", "/tmp/wt/a.rs"]); } #[test] fn editor_at_unknown_editor_appends_file_only() { - let argv = resolve_editor_at_argv("myeditor", "/tmp/wt/a.rs", 3).unwrap(); + let argv = resolve_editor_at_argv("myeditor", "/tmp/wt", "/tmp/wt/a.rs", 3).unwrap(); assert_eq!(argv, vec!["myeditor", "/tmp/wt/a.rs"]); } #[test] fn editor_at_substitutes_placeholders_in_separate_tokens() { - let argv = resolve_editor_at_argv("nvim +{line} {file}", "/tmp/wt/a.rs", 9).unwrap(); + let argv = + resolve_editor_at_argv("nvim +{line} {file}", "/tmp/wt", "/tmp/wt/a.rs", 9).unwrap(); assert_eq!(argv, vec!["nvim", "+9", "/tmp/wt/a.rs"]); } #[test] fn editor_at_wrapper_terminal_editor_keeps_line() { - let argv = resolve_editor_at_argv("alacritty -e nvim", "/wt/a.rs", 42).unwrap(); + let argv = resolve_editor_at_argv("alacritty -e nvim", "/wt", "/wt/a.rs", 42).unwrap(); assert_eq!(argv, vec!["alacritty", "-e", "nvim", "+42", "/wt/a.rs"]); } #[test] fn editor_at_wrapper_gui_editor_uses_goto() { - let argv = resolve_editor_at_argv("wezterm start -- code", "/wt/a.rs", 7).unwrap(); + let argv = resolve_editor_at_argv("wezterm start -- code", "/wt", "/wt/a.rs", 7).unwrap(); assert_eq!( argv, vec!["wezterm", "start", "--", "code", "--goto", "/wt/a.rs:7"] @@ -554,20 +566,20 @@ mod tests { #[test] fn editor_at_zed_uses_goto() { - let argv = resolve_editor_at_argv("zed", "/wt/a.rs", 5).unwrap(); + let argv = resolve_editor_at_argv("zed", "/wt", "/wt/a.rs", 5).unwrap(); assert_eq!(argv, vec!["zed", "--goto", "/wt/a.rs:5"]); } #[test] fn editor_at_nano_uses_plus_line() { - let argv = resolve_editor_at_argv("nano", "/wt/a.rs", 5).unwrap(); + let argv = resolve_editor_at_argv("nano", "/wt", "/wt/a.rs", 5).unwrap(); assert_eq!(argv, vec!["nano", "+5", "/wt/a.rs"]); } #[test] fn editor_at_unknown_wrapped_editor_appends_file_only() { // No known editor token and no placeholders → append the file, line dropped. - let argv = resolve_editor_at_argv("myterm -e myed", "/wt/a.rs", 9).unwrap(); + let argv = resolve_editor_at_argv("myterm -e myed", "/wt", "/wt/a.rs", 9).unwrap(); assert_eq!(argv, vec!["myterm", "-e", "myed", "/wt/a.rs"]); } @@ -575,10 +587,38 @@ mod tests { fn editor_at_first_known_editor_token_wins() { // When more than one known editor appears, the first match decides the // goto style (here `code` → --goto, even though `vim` follows). - let argv = resolve_editor_at_argv("code --diff vim", "/wt/a.rs", 3).unwrap(); + let argv = resolve_editor_at_argv("code --diff vim", "/wt", "/wt/a.rs", 3).unwrap(); assert_eq!(argv, vec!["code", "--diff", "vim", "--goto", "/wt/a.rs:3"]); } + #[test] + fn editor_at_substitutes_path_placeholder() { + // {path} is the worktree (shared with the dir-open action); the inner + // editor is still detected and the line appended. + let argv = + resolve_editor_at_argv("xdg-terminal-exec --dir={path} nvim", "/wt", "/wt/a.rs", 42) + .unwrap(); + assert_eq!( + argv, + vec!["xdg-terminal-exec", "--dir=/wt", "nvim", "+42", "/wt/a.rs"] + ); + } + + #[test] + fn editor_at_substitutes_path_with_file_and_line_placeholders() { + let argv = resolve_editor_at_argv( + "term --dir={path} -- nvim +{line} {file}", + "/wt", + "/wt/a.rs", + 9, + ) + .unwrap(); + assert_eq!( + argv, + vec!["term", "--dir=/wt", "--", "nvim", "+9", "/wt/a.rs"] + ); + } + #[test] fn editor_decision_needs_config_when_unset_or_blank() { assert_eq!(editor_open_decision(None), EditorOpenDecision::NeedsConfig); From 32e137b4fe7593b7fc49ef4c447f2d815e52ccfe Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 15:08:24 -0400 Subject: [PATCH 47/69] fix(chronology): resolve editor line from the new (post-edit) text, not old --- src/activity/chronology.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index 38cc876..b7c8546 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -287,11 +287,12 @@ mod extract_tests { } /// 1-based line to open the editor at, given the file's current contents and -/// the change detail. For an Edit, locate the first line of `old` in `contents`; -/// for a Write (or anything not found), line 1. +/// the change detail. The chronology records changes that were already applied, +/// so the file holds the NEW text — locate the first non-blank line of `new` in +/// `contents`; for a Write (or anything not found), line 1. pub fn resolve_line(contents: &str, detail: &ChangeDetail) -> u32 { let needle = match detail { - ChangeDetail::Edit { old, .. } => old.lines().find(|l| !l.trim().is_empty()), + ChangeDetail::Edit { new, .. } => new.lines().find(|l| !l.trim().is_empty()), _ => None, }; let Some(needle) = needle else { return 1 }; @@ -317,8 +318,10 @@ mod line_tests { use super::*; #[test] - fn finds_line_of_old_string_first_line() { - let file = "fn a() {}\nfn b() {}\nfn c() {}\n"; + fn finds_line_of_new_string_first_line() { + // The edit was already applied, so the file holds the NEW text and the + // `old` string is gone. resolve_line must locate the NEW line. + let file = "fn a() {}\nfn b2() {}\nfn c() {}\n"; let detail = ChangeDetail::Edit { old: "fn b() {}".into(), new: "fn b2() {}".into(), @@ -335,10 +338,10 @@ mod line_tests { } #[test] - fn missing_old_string_falls_back_to_line_one() { + fn missing_new_string_falls_back_to_line_one() { let detail = ChangeDetail::Edit { - old: "nonexistent".into(), - new: "x".into(), + old: "x".into(), + new: "nonexistent".into(), }; assert_eq!(resolve_line("fn a() {}\n", &detail), 1); } From 108116fe053eb338c97d42d082d02f7401c07182 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 15:16:49 -0400 Subject: [PATCH 48/69] docs: design spec for chronology detail line numbers --- ...6-chronology-detail-line-numbers-design.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-06-chronology-detail-line-numbers-design.md diff --git a/docs/superpowers/specs/2026-06-06-chronology-detail-line-numbers-design.md b/docs/superpowers/specs/2026-06-06-chronology-detail-line-numbers-design.md new file mode 100644 index 0000000..d8951b5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-chronology-detail-line-numbers-design.md @@ -0,0 +1,124 @@ +# Chronology Detail Line Numbers — Design + +**Date:** 2026-06-06 +**Status:** Approved for planning +**Builds on:** the Change Chronology bar (expandable detail "diff peek"). + +## Problem + +When a chronology entry is expanded, its detail peek shows the change as removed +(`- old`) and added (`+ new`) lines, but with no line numbers. The reader can't +tell *where* in the file the change lives without opening the editor. + +## Goal + +Show a line-number gutter on the detail peek. Only the **added** lines have a +current-file line number (the removed lines no longer exist in the file), so: + +- `+` added lines (and `Write` content lines) are numbered with their + current-file line, starting at the change's resolved line and incrementing. +- `-` removed lines render with a blank gutter (no current-file line). + +This reuses the line wsx already computes for "open at line," so the gutter and +the editor jump agree. + +## Decisions (from brainstorming) + +- **Option A:** keep the `- removed` lines (blank gutter) alongside the numbered + `+ added` lines — preserves before/after context. (Chosen over dropping the + removed lines.) +- The starting line comes from the existing `resolve_line_in_file(file, detail)` + (which anchors on the first non-blank line of the post-edit `new` text). +- `entry_lines` stays pure: the renderer does the file IO to compute the base + line and passes it in. + +## Current behavior (for reference) + +`src/ui/chronology_bar.rs::entry_lines(ev, worktree, expanded, width, highlight)` +builds the peek (only when `expanded`): +- `ChangeDetail::Edit { old, new }` → up to 2 `- {old_line}` then up to 2 + `+ {new_line}`. +- `ChangeDetail::Write { head }` → up to 3 `+ {content_line}`. +- `ChangeDetail::None` → no peek. +Each peek line is dimmed and clipped to `width`. + +`resolve_line_in_file(path, detail) -> u32` (in `src/activity/chronology.rs`) +reads the file and returns the 1-based line of the first non-blank `new` line, +or 1 when not found / Write / unreadable. + +## Design + +### Gutter format + +A fixed-width, right-aligned line-number gutter precedes the existing `±` +marker and text. Gutter width: `GUTTER = 4` columns (line number, right-aligned) +plus one trailing space, so the body starts at column 5. + +- Added line at file line `n`: `"{n:>4} + {text}"` → e.g. `" 42 + let x = 2;"`. +- Removed line: blank gutter → `" - {text}"` (4 spaces + space, then marker). +- Line numbers ≥ 10000 simply widen the field (Rust `{:>4}` does not truncate); + acceptable for a glance. + +The whole composed line is then clipped to `width` (as today), so the gutter is +preserved and the text tail is what gets cut on a narrow bar. + +### Numbering + +For the expanded entry, the renderer computes `base_line = +resolve_line_in_file(&ev.file_path, &ev.detail)` and passes it to `entry_lines`. + +- `Edit { old, new }`: removed lines (`old.lines().take(2)`) get a blank gutter; + added lines (`new.lines().take(2)`) get `base_line`, `base_line + 1`. +- `Write { head }`: content lines (`head.lines().take(3)`) get `base_line`, + `base_line + 1`, `base_line + 2` (`base_line` is 1 for a Write). +- Numbering is best-effort (consistent with the editor-open line): it assumes the + shown added lines are contiguous from `base_line`. If a later edit moved the + text, the gutter may be approximate — acceptable for a glanceable peek. + +### Components / files + +- **`src/ui/chronology_bar.rs`** + - `entry_lines` gains a `base_line: u32` parameter (after `width`). + - Refactor the peek construction so each peek line carries its gutter: + build `(gutter: Option, marker: char, text: &str)` tuples — `Some(n)` + for added lines (numbered from `base_line`), `None` for removed lines — then + format `"{:>4} {} {}"` / `" {} {}"` and clip to `width`. + - The `EntryHighlight::Detail` highlight still reverses the peek lines (now + including the gutter), unchanged in index range (peek lines remain + everything after the header). +- **`src/ui/attached.rs` (`render_chronology_bar`)** + - For the expanded entry, compute `base_line = + crate::activity::chronology::resolve_line_in_file(&ev.file_path, &ev.detail)` + and pass it into the `entry_lines(...)` call. For non-expanded entries pass + any value (e.g. `1`) — the peek isn't rendered, so it's unused. (Compute it + only when `expanded` to avoid needless file reads.) +- No change to `resolve_line_in_file` or the editor-open path. + +### Error handling / edge cases + +- File unreadable / change not found → `resolve_line_in_file` returns 1; the + gutter still numbers from 1 (best-effort, no error). +- `Write` → `base_line` is 1, so content numbers 1, 2, 3. +- Narrow bar → the composed `"{gutter} {marker} {text}"` is clipped to `width`; + the gutter stays, the text tail is trimmed (gutter is the high-value part). +- Empty `new`/`old` → fewer peek lines, no panic (existing `take(2)`/`take(3)`). + +### Testing + +- **`entry_lines` (pure):** + - Edit at `base_line = 42`: the added (`+`) peek lines carry `42`, `43` in a + right-aligned gutter; the removed (`-`) peek lines have a blank (spaces) + gutter. Assert the rendered line strings contain `"42"`/`"43"` on the `+` + lines and that the `-` lines' gutter region is spaces. + - Write at `base_line = 1`: content lines carry `1`, `2`, `3`. + - Collapsed entry: unchanged (single header line; `base_line` ignored). + - Existing highlight tests updated for the new `entry_lines` arity; `Detail` + highlight still reverses peek lines. +- The renderer's `base_line` computation is the existing tested + `resolve_line_in_file`; the wiring is verified by build + manual. + +## Files touched + +- `src/ui/chronology_bar.rs` — `entry_lines` gutter + `base_line` param + tests. +- `src/ui/attached.rs` — compute and pass `base_line` for the expanded entry. +- `README.md` — note the detail peek shows line numbers on added lines. From e9d5e1c57b258fdcb97980344b4ebb48262fbe27 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 15:20:38 -0400 Subject: [PATCH 49/69] docs: implementation plan for chronology detail line numbers --- ...26-06-06-chronology-detail-line-numbers.md | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-06-chronology-detail-line-numbers.md diff --git a/docs/superpowers/plans/2026-06-06-chronology-detail-line-numbers.md b/docs/superpowers/plans/2026-06-06-chronology-detail-line-numbers.md new file mode 100644 index 0000000..891101f --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-chronology-detail-line-numbers.md @@ -0,0 +1,280 @@ +# Chronology Detail Line Numbers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a line-number gutter on the chronology detail peek — added (`+`) lines numbered from the change's resolved line, removed (`-`) lines with a blank gutter. + +**Architecture:** `entry_lines` (pure) gains a `base_line: u32` param and renders each peek line with a 4-col right-aligned gutter (number for `+` lines, spaces for `-` lines). The renderer computes `base_line` for the expanded entry via the existing `resolve_line_in_file` (file IO stays out of the pure function). + +**Tech Stack:** Rust, `ratatui`. Tests are `#[cfg(test)]` unit tests via `cargo test`. + +**Builds on (verified current code):** +- `src/ui/chronology_bar.rs::entry_lines(ev: &ChangeEvent, worktree: &Path, expanded: bool, width: u16, highlight: EntryHighlight) -> Vec>`. When `expanded`, it builds `peek: Vec` — `Edit { old, new }` → up to 2 `format!("- {l}")` then up to 2 `format!("+ {l}")`; `Write { head }` → up to 3 `format!("+ {l}")`; `None` → empty — then pushes each, clipped to `width`, dimmed. The `EntryHighlight::Detail` arm reverses `out.iter_mut().skip(1)` (peek = everything after the header at index 0). +- The ONLY non-test caller is `src/ui/attached.rs::render_chronology_bar` (~line 340): `entry_lines(ev, draw.worktree, expanded, inner_width, highlight)`, inside a loop where `let expanded = Some(i) == draw.expanded;` and `ev` is `&ChangeEvent` (has `file_path: PathBuf`, `detail: ChangeDetail`). +- Test callers in `chronology_bar.rs`: `collapsed_entry_is_a_single_header_line`, `expanded_entry_adds_diff_peek_lines`, `header_highlight_reverses_first_line`, `detail_highlight_reverses_peek_lines_only`, `no_highlight_leaves_lines_unreversed` — each calls `entry_lines(ev(...), Path::new("/wt"), , 40, EntryHighlight::X)`. +- `crate::activity::chronology::resolve_line_in_file(path: &Path, detail: &ChangeDetail) -> u32` (returns the 1-based line of the first non-blank `new` line; 1 for Write / not-found / unreadable). + +--- + +## File Structure + +- `src/ui/chronology_bar.rs` (modify) — `entry_lines` gains `base_line: u32`; peek lines get a gutter; tests updated + 2 new gutter tests. +- `src/ui/attached.rs` (modify) — compute `base_line` for the expanded entry and pass it. +- `README.md` (modify) — note the detail peek shows line numbers on added lines. + +--- + +## Task 1: Gutter + `base_line` param in `entry_lines` + +**Files:** +- Modify: `src/ui/chronology_bar.rs` +- Modify: `src/ui/attached.rs` (only the single `entry_lines` call site — arity bump with a placeholder, so the crate compiles; the real value is wired in Task 2) + +- [ ] **Step 1: Write the failing tests** + +In `src/ui/chronology_bar.rs`'s `#[cfg(test)] mod tests`, add a small text helper and two gutter tests: + +```rust + fn line_text(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn peek_numbers_added_lines_and_blanks_removed_gutter() { + let ev = ChangeEvent { + timestamp_ms: 0, + tool: ChangeTool::Edit, + file_path: PathBuf::from("/wt/a.rs"), + summary: String::new(), + detail: ChangeDetail::Edit { + old: "old0\nold1".into(), + new: "new0\nnew1".into(), + }, + }; + let lines = entry_lines(&ev, Path::new("/wt"), true, 60, 42, EntryHighlight::None); + let texts: Vec = lines.iter().map(line_text).collect(); + // out[0] header; out[1..3] removed (blank gutter); out[3..5] added (42, 43) + assert!(texts[1].starts_with(" -"), "removed gutter blank: {:?}", texts[1]); + assert!(texts[2].starts_with(" -"), "{:?}", texts[2]); + assert!(texts[3].contains("42") && texts[3].contains("+ new0"), "{:?}", texts[3]); + assert!(texts[4].contains("43") && texts[4].contains("+ new1"), "{:?}", texts[4]); + } + + #[test] + fn write_peek_numbers_from_base_line() { + let ev = ChangeEvent { + timestamp_ms: 0, + tool: ChangeTool::Write, + file_path: PathBuf::from("/wt/a.rs"), + summary: String::new(), + detail: ChangeDetail::Write { + head: "l1\nl2\nl3".into(), + }, + }; + let lines = entry_lines(&ev, Path::new("/wt"), true, 60, 1, EntryHighlight::None); + let texts: Vec = lines.iter().map(line_text).collect(); + assert!(texts[1].contains("1") && texts[1].contains("+ l1"), "{:?}", texts[1]); + assert!(texts[2].contains("2") && texts[2].contains("+ l2"), "{:?}", texts[2]); + assert!(texts[3].contains("3") && texts[3].contains("+ l3"), "{:?}", texts[3]); + } +``` + +Also UPDATE the 5 existing `entry_lines(...)` calls in this test module to insert the new `base_line` argument (use `1`) as the 5th argument, before `EntryHighlight::...`. For example: + +```rust + let lines = entry_lines( + &ev("/wt/src/main.rs", "fn foo()"), + Path::new("/wt"), + false, + 40, + 1, + EntryHighlight::None, + ); +``` + +Apply the same insertion to `expanded_entry_adds_diff_peek_lines`, `header_highlight_reverses_first_line`, `detail_highlight_reverses_peek_lines_only`, and `no_highlight_leaves_lines_unreversed`. + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib chronology_bar` +Expected: FAIL — arity mismatch (`entry_lines` takes 5 args) / the two new tests don't compile yet. + +- [ ] **Step 3: Add the `base_line` param and gutter** + +In `src/ui/chronology_bar.rs`, change the signature (add `base_line: u32` after `width`): + +```rust +pub fn entry_lines( + ev: &ChangeEvent, + worktree: &Path, + expanded: bool, + width: u16, + base_line: u32, + highlight: EntryHighlight, +) -> Vec> { +``` + +Replace the `if expanded { ... }` peek block with a gutter-aware version: + +```rust + if expanded { + // (line number, marker, text). `+` (added) lines carry a current-file + // line number starting at base_line; `-` (removed) lines have none + // (they no longer exist in the file). + let mut peek: Vec<(Option, char, String)> = Vec::new(); + match &ev.detail { + ChangeDetail::Edit { old, new } => { + for l in old.lines().take(2) { + peek.push((None, '-', l.to_string())); + } + for (k, l) in new.lines().take(2).enumerate() { + peek.push((Some(base_line.saturating_add(k as u32)), '+', l.to_string())); + } + } + ChangeDetail::Write { head } => { + for (k, l) in head.lines().take(3).enumerate() { + peek.push((Some(base_line.saturating_add(k as u32)), '+', l.to_string())); + } + } + ChangeDetail::None => {} + } + for (gutter, marker, text) in peek { + // 4-col right-aligned number + a space, then marker + text. Removed + // lines use a 5-space blank gutter so columns line up. + let line = match gutter { + Some(n) => format!("{n:>4} {marker} {text}"), + None => format!(" {marker} {text}"), + }; + let clipped: String = line.chars().take(width as usize).collect(); + out.push(Line::from(Span::styled( + clipped, + Style::default().add_modifier(Modifier::DIM), + ))); + } + } +``` + +The `match highlight { ... }` block below is unchanged (the `Detail` arm still reverses `out.iter_mut().skip(1)`). Update the doc comment above `entry_lines` to mention the numbered gutter, e.g. append: "Peek lines carry a line-number gutter: added (`+`) lines are numbered from `base_line`, removed (`-`) lines have a blank gutter." + +- [ ] **Step 4: Bump the attached.rs call site (placeholder)** + +In `src/ui/attached.rs::render_chronology_bar`, the call is: + +```rust + let lines = crate::ui::chronology_bar::entry_lines( + ev, + draw.worktree, + expanded, + inner_width, + highlight, + ); +``` + +Insert `1,` as the `base_line` argument before `highlight` so the crate compiles (the real computation lands in Task 2): + +```rust + let lines = crate::ui::chronology_bar::entry_lines( + ev, + draw.worktree, + expanded, + inner_width, + 1, + highlight, + ); +``` + +- [ ] **Step 5: Run to verify pass** + +Run: `cargo test --lib chronology_bar` +Expected: PASS — the 2 new gutter tests plus the updated existing tests. +Run: `cargo build` — zero warnings. `cargo fmt` then `cargo fmt --check` — clean (only the two files; revert out-of-scope via `git checkout`). + +- [ ] **Step 6: Commit** + +```bash +git add src/ui/chronology_bar.rs src/ui/attached.rs +git commit -m "feat(chronology): line-number gutter on detail peek (+lines numbered, -blank)" +``` + +--- + +## Task 2: Compute the real `base_line` in the renderer + +**Files:** +- Modify: `src/ui/attached.rs` + +No isolated unit test (file IO + render side-effects); `resolve_line_in_file` is already tested, and the gutter formatting is tested in Task 1. Verified by build + manual. + +- [ ] **Step 1: Compute `base_line` for the expanded entry** + +In `src/ui/attached.rs::render_chronology_bar`, just before the `entry_lines(...)` call (after `let highlight = ...;`), add: + +```rust + // Number the detail peek from the change's resolved line; only the + // expanded entry shows a peek, so only it needs the (file-reading) lookup. + let base_line = if expanded { + crate::activity::chronology::resolve_line_in_file(&ev.file_path, &ev.detail) + } else { + 1 + }; +``` + +Then change the `entry_lines(...)` call's `base_line` argument from the placeholder `1` to `base_line`: + +```rust + let lines = crate::ui::chronology_bar::entry_lines( + ev, + draw.worktree, + expanded, + inner_width, + base_line, + highlight, + ); +``` + +- [ ] **Step 2: Build + manual verify** + +Run: `cargo build` — zero warnings. `cargo test --lib` — no regressions. `cargo fmt --check` — clean. +Manual: in an attached Claude workspace, expand a chronology entry — the diff peek's `+` lines show right-aligned line numbers matching the file; `-` lines have a blank gutter; clicking/Enter still opens the editor at the same first `+` line number. + +- [ ] **Step 3: Commit** + +```bash +git add src/ui/attached.rs +git commit -m "feat(chronology): number the detail peek from the change's resolved line" +``` + +--- + +## Task 3: README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Document the gutter** + +In the "Change chronology" section of `README.md` where the expandable detail / diff peek is described, add a sentence: expanding an entry shows a short diff peek with a line-number gutter — added lines are numbered with their current file line (matching where "open in editor" jumps), removed lines have a blank gutter. Match the existing prose style. + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: note line-number gutter in the chronology detail peek" +``` + +--- + +## Self-Review (completed during planning) + +**Spec coverage:** +- Numbered `+` lines, blank gutter on `-` lines (Option A) → Task 1 (gutter tuples + format). ✓ +- `base_line` from `resolve_line_in_file` (agrees with editor jump) → Task 2. ✓ +- `entry_lines` stays pure; renderer does the IO → Task 1 (param) + Task 2 (compute). ✓ +- IO only for the expanded entry → Task 2 (`if expanded`). ✓ +- Gutter preserved on clip (text tail trimmed) → Task 1 (compose full line incl. gutter, then `chars().take(width)`). ✓ +- `Detail` highlight still reverses peek lines → Task 1 (highlight block unchanged; gutter is part of each peek span). ✓ +- README → Task 3. ✓ + +**Placeholder scan:** No vague steps; every code step shows complete code. The Task 1→Task 2 placeholder (`1`) is an explicit, named compile-bridge, replaced in Task 2. + +**Type consistency:** `entry_lines(ev, worktree, expanded, width, base_line: u32, highlight)` arity is consistent across the call site (attached.rs) and all 7 test calls; `resolve_line_in_file(&ev.file_path, &ev.detail) -> u32` matches the `base_line` type; `line_text` helper used only in the new tests. From f5a07700047ba7f9526acbb6c5265d061ddde111 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 15:25:47 -0400 Subject: [PATCH 50/69] feat(chronology): line-number gutter on detail peek (+lines numbered, -blank) --- src/ui/attached.rs | 1 + src/ui/chronology_bar.rs | 109 ++++++++++++++++++++++++++++++++++----- 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/src/ui/attached.rs b/src/ui/attached.rs index 8523ada..6ea9752 100644 --- a/src/ui/attached.rs +++ b/src/ui/attached.rs @@ -342,6 +342,7 @@ fn render_chronology_bar( draw.worktree, expanded, inner_width, + 1, highlight, ); let available = body_bottom.saturating_sub(cursor_y); diff --git a/src/ui/chronology_bar.rs b/src/ui/chronology_bar.rs index 97c9bcf..72071ff 100644 --- a/src/ui/chronology_bar.rs +++ b/src/ui/chronology_bar.rs @@ -94,12 +94,16 @@ pub enum EntryHighlight { /// Render one entry into lines. Line 1: `HH:MM `, where the path is /// abbreviated (ancestor dirs collapsed to their first letter) to fit the -/// width. When `expanded`, appends up to a few diff-peek lines from `detail`. +/// width. When `expanded`, appends up to a few diff-peek lines from `detail`, +/// each prefixed with a 4-wide line-number gutter: added (`+`) lines are +/// numbered from `base_line`, removed (`-`) lines have a 5-space blank gutter +/// so columns align. pub fn entry_lines( ev: &ChangeEvent, worktree: &Path, expanded: bool, width: u16, + base_line: u32, highlight: EntryHighlight, ) -> Vec> { let mut out = Vec::new(); @@ -117,24 +121,33 @@ pub fn entry_lines( Span::raw(path), ])); if expanded { - let peek: Vec = match &ev.detail { + // (line number, marker, text). `+` (added) lines carry a current-file + // line number starting at base_line; `-` (removed) lines have none. + let mut peek: Vec<(Option, char, String)> = Vec::new(); + match &ev.detail { ChangeDetail::Edit { old, new } => { - let mut v = Vec::new(); for l in old.lines().take(2) { - v.push(format!("- {l}")); + peek.push((None, '-', l.to_string())); } - for l in new.lines().take(2) { - v.push(format!("+ {l}")); + for (k, l) in new.lines().take(2).enumerate() { + peek.push((Some(base_line.saturating_add(k as u32)), '+', l.to_string())); } - v } ChangeDetail::Write { head } => { - head.lines().take(3).map(|l| format!("+ {l}")).collect() + for (k, l) in head.lines().take(3).enumerate() { + peek.push((Some(base_line.saturating_add(k as u32)), '+', l.to_string())); + } } - ChangeDetail::None => Vec::new(), - }; - for l in peek { - let clipped: String = l.chars().take(width as usize).collect(); + ChangeDetail::None => {} + } + for (gutter, marker, text) in peek { + // 4-col right-aligned number + a space, then marker + text. Removed + // lines use a 5-space blank gutter so the columns line up. + let line = match gutter { + Some(n) => format!("{n:>4} {marker} {text}"), + None => format!(" {marker} {text}"), + }; + let clipped: String = line.chars().take(width as usize).collect(); out.push(Line::from(Span::styled( clipped, Style::default().add_modifier(Modifier::DIM), @@ -206,6 +219,7 @@ mod tests { Path::new("/wt"), false, 40, + 1, EntryHighlight::None, ); assert_eq!( @@ -222,6 +236,7 @@ mod tests { Path::new("/wt"), true, 40, + 1, EntryHighlight::None, ); assert!( @@ -266,6 +281,7 @@ mod tests { Path::new("/wt"), true, 40, + 1, EntryHighlight::Header, ); let has_rev = lines[0] @@ -282,6 +298,7 @@ mod tests { Path::new("/wt"), true, 40, + 1, EntryHighlight::Detail, ); assert!( @@ -306,6 +323,7 @@ mod tests { Path::new("/wt"), false, 40, + 1, EntryHighlight::None, ); assert!( @@ -316,4 +334,71 @@ mod tests { "no highlight expected" ); } + + fn line_text(line: &Line<'_>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn peek_numbers_added_lines_and_blanks_removed_gutter() { + let ev = ChangeEvent { + timestamp_ms: 0, + tool: ChangeTool::Edit, + file_path: PathBuf::from("/wt/a.rs"), + summary: String::new(), + detail: ChangeDetail::Edit { + old: "old0\nold1".into(), + new: "new0\nnew1".into(), + }, + }; + let lines = entry_lines(&ev, Path::new("/wt"), true, 60, 42, EntryHighlight::None); + let texts: Vec = lines.iter().map(line_text).collect(); + // out[0] header; out[1..3] removed (blank gutter); out[3..5] added (42, 43) + assert!( + texts[1].starts_with(" -"), + "removed gutter blank: {:?}", + texts[1] + ); + assert!(texts[2].starts_with(" -"), "{:?}", texts[2]); + assert!( + texts[3].contains("42") && texts[3].contains("+ new0"), + "{:?}", + texts[3] + ); + assert!( + texts[4].contains("43") && texts[4].contains("+ new1"), + "{:?}", + texts[4] + ); + } + + #[test] + fn write_peek_numbers_from_base_line() { + let ev = ChangeEvent { + timestamp_ms: 0, + tool: ChangeTool::Write, + file_path: PathBuf::from("/wt/a.rs"), + summary: String::new(), + detail: ChangeDetail::Write { + head: "l1\nl2\nl3".into(), + }, + }; + let lines = entry_lines(&ev, Path::new("/wt"), true, 60, 1, EntryHighlight::None); + let texts: Vec = lines.iter().map(line_text).collect(); + assert!( + texts[1].contains("1") && texts[1].contains("+ l1"), + "{:?}", + texts[1] + ); + assert!( + texts[2].contains("2") && texts[2].contains("+ l2"), + "{:?}", + texts[2] + ); + assert!( + texts[3].contains("3") && texts[3].contains("+ l3"), + "{:?}", + texts[3] + ); + } } From c76389eeab4e19e3ebc33ce7a8c64a3e97f3d62b Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 15:29:07 -0400 Subject: [PATCH 51/69] test(chronology): assert full gutter in write peek test --- src/ui/chronology_bar.rs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/ui/chronology_bar.rs b/src/ui/chronology_bar.rs index 72071ff..e3aea5d 100644 --- a/src/ui/chronology_bar.rs +++ b/src/ui/chronology_bar.rs @@ -385,20 +385,9 @@ mod tests { }; let lines = entry_lines(&ev, Path::new("/wt"), true, 60, 1, EntryHighlight::None); let texts: Vec = lines.iter().map(line_text).collect(); - assert!( - texts[1].contains("1") && texts[1].contains("+ l1"), - "{:?}", - texts[1] - ); - assert!( - texts[2].contains("2") && texts[2].contains("+ l2"), - "{:?}", - texts[2] - ); - assert!( - texts[3].contains("3") && texts[3].contains("+ l3"), - "{:?}", - texts[3] - ); + // Assert the full gutter so a missing number can't pass via "l1" etc. + assert_eq!(texts[1], " 1 + l1", "{:?}", texts[1]); + assert_eq!(texts[2], " 2 + l2", "{:?}", texts[2]); + assert_eq!(texts[3], " 3 + l3", "{:?}", texts[3]); } } From 03622af57230933d59b4bb110a027ee0406a3abe Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 15:30:20 -0400 Subject: [PATCH 52/69] feat(chronology): number the detail peek from the change's resolved line --- src/ui/attached.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ui/attached.rs b/src/ui/attached.rs index 6ea9752..34c6b19 100644 --- a/src/ui/attached.rs +++ b/src/ui/attached.rs @@ -337,12 +337,19 @@ fn render_chronology_bar( } else { EntryHighlight::None }; + // Number the detail peek from the change's resolved line. Only the + // expanded entry shows a peek, so only it needs the (file-reading) lookup. + let base_line = if expanded { + crate::activity::chronology::resolve_line_in_file(&ev.file_path, &ev.detail) + } else { + 1 + }; let lines = crate::ui::chronology_bar::entry_lines( ev, draw.worktree, expanded, inner_width, - 1, + base_line, highlight, ); let available = body_bottom.saturating_sub(cursor_y); From 40167a4b3da336da645ad73fc287bb1b45979513 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 15:31:06 -0400 Subject: [PATCH 53/69] docs: note line-number gutter in the chronology detail peek --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 55c3c63..ef6c099 100644 --- a/README.md +++ b/README.md @@ -611,7 +611,7 @@ The rename only fires on workspaces whose name still matches the generated ` Date: Sat, 6 Jun 2026 18:08:36 -0400 Subject: [PATCH 54/69] docs: design spec for chronology detail modal --- ...26-06-06-chronology-detail-modal-design.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-06-chronology-detail-modal-design.md diff --git a/docs/superpowers/specs/2026-06-06-chronology-detail-modal-design.md b/docs/superpowers/specs/2026-06-06-chronology-detail-modal-design.md new file mode 100644 index 0000000..fa8602b --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-chronology-detail-modal-design.md @@ -0,0 +1,235 @@ +# Chronology Detail Modal — Design + +**Date:** 2026-06-06 +**Status:** Approved for planning +**Builds on / revises:** the Change Chronology bar, its keyboard navigation, and the detail line-number gutter. + +## Problem + +The detail view is an inline peek inside the narrow docked bar, capped at a few +lines. Seeing the *entire* change there is impractical — the vertical bar is the +wrong surface for a full diff. + +## Goal + +Keep the docked bar as the glanceable, navigable **timeline list**, but show a +selected change's **full** diff in a large, scrollable **modal overlay**. + +- The bar becomes list-only: selecting a change opens the modal. +- The modal shows the entire change (untruncated), scrollable by keyboard + (home-row + arrows + page/top-bottom) and mouse wheel. +- Opening the change's file in the editor moves into the modal (`e`). + +## Scope + +- **In scope:** a `Modal::ChangeDetail` overlay; opening it from the bar; + full-change re-extraction on demand; the bar/keyboard/state simplification + that the modal makes possible. +- **Superseded and removed** (the modal replaces them): the inline detail peek, + expand/collapse, the two-level `Entry`/`Detail` cursor, the in-bar + line-number gutter, and the in-bar detail click target. The bar's + open-in-editor two-step is replaced by `e` inside the modal. +- **Out of scope:** horizontal scrolling in the modal (long lines clip); + syntax highlighting; other agents (still the separate deferred follow-up). + +## Decisions (from brainstorming) + +- **A (chosen):** bar stays as the list; modal is the detail viewer (not B — + replacing the bar with a modal chronology). +- **Re-extract the full change on demand** when the modal opens (vs storing + every change's full text in memory). The in-memory `Timeline` keeps the + existing 600-char clip for the list/line-resolution; the modal reads the full + text for the one opened change. +- Modal opens on `Enter` (keyboard) / click (mouse) on a bar entry; `Esc` + closes; `e` opens the editor at the change's line. + +## Architecture + +``` +bar entry (Enter/click) + → load_full_change(event) [re-read the session-log line, un-clipped] + → format_change_lines(full_detail, base_line) [full diff + line-number gutter] + → Modal::ChangeDetail { title, lines, scroll, worktree, file, line } + → render overlay (scroll slice) ; keys/wheel adjust scroll ; Esc closes ; e → editor +``` + +## Components + +### 1. Full-change re-extraction (`src/activity/chronology.rs`) + +The `Timeline` holds every change in the whole-workspace history, so per-change +detail stays clipped (memory bound). The modal needs the full text for one +change, read on demand from the session log. + +- **`ChangeSource`** added to `ChangeEvent`: + ```rust + pub struct ChangeSource { + pub session_file: PathBuf, // the JSONL log this event came from + pub line_index: usize, // 0-based line number within that file + pub index_in_line: usize, // position among the events that line produced + } + ``` + `ChangeEvent` gains `pub source: ChangeSource`. +- **`extract_change_events` gains a clip bound:** + `extract_change_events(v: &serde_json::Value, detail_max: usize) -> Vec`. + `clip` becomes `clip(s, max)`. It sets each emitted event's + `source.index_in_line` to its position in the returned `Vec` and leaves + `session_file`/`line_index` default (the caller fills them). +- **`parse_file`** passes `DETAIL_MAX_CHARS` and, for each parsed line (tracked + via `enumerate`), sets `source.session_file = path` and + `source.line_index = line_index` on every event that line produced. +- **`load_full_change(ev: &ChangeEvent) -> Option`**: open + `ev.source.session_file`, read the line at `line_index`, parse it, call + `extract_change_events(&v, usize::MAX)`, and return + `.get(ev.source.index_in_line)?.detail.clone()` (the full, un-clipped detail). + Returns `None` when the source is empty/unreadable/line gone — callers fall + back to the event's clipped `detail`. + + Because both the clipped build and the full re-extract use the *same* + `extract_change_events` walk, `index_in_line` aligns by construction (no + duplicated parsing logic to drift). + +### 2. Full diff formatting (shared, pure) + +Factor the gutter/diff formatting (currently inside `entry_lines`) into a +reusable pure function in `src/ui/chronology_bar.rs`: + +```rust +/// Full change as display strings with a line-number gutter: removed (`-`) +/// lines (blank gutter) then added (`+`) lines numbered from `base_line`. +/// No line cap — the modal scrolls. +pub fn change_detail_lines(detail: &ChangeDetail, base_line: u32) -> Vec +``` + +- `Edit { old, new }`: every `old` line → `" - {l}"`; every `new` line → + `"{n:>4} + {l}"` from `base_line` (saturating). +- `Write { head }`: every content line → `"{n:>4} + {l}"` from `base_line`. +- `None`: empty. +This is the same gutter scheme already shipped, minus the `take(2)`/`take(3)` +caps. The modal renderer owns horizontal clipping to its width. + +### 3. The bar becomes list-only (`src/ui/chronology_bar.rs`, `src/ui/attached.rs`) + +- `entry_lines` reduces to the single header line (`HH:MM `). + Its signature becomes `entry_lines(ev, worktree, width, selected: bool)`: + remove the `expanded` param, the peek block, the `base_line` param, and the + `EntryHighlight` enum entirely — a row is just selected or not, so a + `selected: bool` (reverses the header line when true) replaces `EntryHighlight`. +- `render_chronology_bar` removes the expand/peek/detail-rect logic; it renders + one header line per entry with selection highlight and records per-entry click + rects (to open the modal). `ChronologyHits` drops `detail`; keep `entries` + and `visible_entries`. + +### 4. Navigation reducer simplification (`src/ui/chronology_nav.rs`) + +Single-level now: +- `ChronoSel` collapses to a plain selected index (`usize`); remove the + `Entry`/`Detail` enum. +- `NavAction` becomes `{ None, Open(usize), Exit }` (remove `Expand`/`Collapse`). +- `nav(sel: usize, key: NavKey, len: usize) -> (usize, NavAction)`: + `Up`/`Down` move with clamp; `Top`/`Bottom`; `Enter` → `Open(sel)`; `Esc` → + `Exit`. `adjust_scroll` is unchanged (operates on the index). + +### 5. App state (`src/app.rs`) + +- Remove: `chronology_expanded`, `chronology_detail_rect`. +- Change: `chronology_sel: usize` (was `ChronoSel`). +- Keep: `chronology_focused`, `chronology_scroll`, `chronology_visible_entries`, + `chronology_entry_rects`, `chronology_bar_rect`, `chronology_last_workspace` + (the reset hook now resets `chronology_sel = 0`, `chronology_focused = false`). + +### 6. The modal (`src/ui/modal.rs`, `src/app/render.rs`, `src/app/input.rs`) + +- **Variant:** + ```rust + Modal::ChangeDetail { + title: String, // "HH:MM " + lines: Vec, // full diff, gutter-formatted (computed at open) + scroll: usize, // top visible line + worktree: PathBuf, // for the editor open + file: PathBuf, // absolute changed file + line: u32, // resolved change line (for `e`) + } + ``` +- **Open** (`src/app/input.rs`): the bar's `NavAction::Open(i)` (keyboard + `Enter`) and an entry click resolve the focused workspace + event `i`, then: + `detail = load_full_change(ev).unwrap_or_else(|| ev.detail.clone())`; + `line = resolve_line_in_file(&ev.file_path, &detail)`; + `lines = change_detail_lines(&detail, line)`; + `title = "{HH:MM} {relative path}"`; set `app.modal = Some(Modal::ChangeDetail + { … scroll: 0 })`. (`open_focused_change` is repurposed from "open editor" to + "open modal".) +- **Render** (`src/app/render.rs` modal dispatch): a bordered overlay sized to + most of the screen (e.g. ~90% width/height, centered); header row = `title`; + body = `lines[scroll .. scroll + body_height]`, each clipped to inner width; + a footer hint (`↑/↓ j/k scroll · e editor · Esc close`) and a simple + position indicator (`scroll+1`–`end` / `len`). +- **Input** (`src/app/input.rs` modal handler): add a `Modal::ChangeDetail` arm: + `↓`/`j` → `scroll = (scroll+1).min(max)`, `↑`/`k` → `saturating_sub(1)`, + `PgDn`/`PgUp` → ± a page, `g`/`G` → top/bottom (`max = lines.len().saturating_ + sub(body_height)`; since the handler may not know `body_height`, page = a fixed + step e.g. 10, and `G` sets a large value clamped at render — store `scroll` + and clamp in the renderer too). `Esc` → `app.modal = None`. `e` → open the + editor via the existing `editor_open_decision` + `open_in_editor_at(&worktree, + &file, line, …)`; on `NeedsConfig`/`Err` replace with `Modal::Error`. + Mouse: wheel over the modal adjusts `scroll`; click outside the modal box + closes it. + + Scroll clamping: the input handler clamps with a conservative `max = + lines.len().saturating_sub(1)`; the renderer additionally clamps `scroll` so + the last page isn't over-scrolled (it knows the body height). Keep a small + pure `clamp_scroll(scroll, len, body) -> usize` helper, unit-tested. + +## Interaction summary + +| Surface | Key/action | Effect | +| --- | --- | --- | +| Bar | `j`/`k`, `↑`/`↓`, `g`/`G` | move selection (unchanged) | +| Bar | `Enter` / click entry | open the change in the modal | +| Bar | `Ctrl-x`+arrow | focus/exit the bar (unchanged) | +| Modal | `↑`/`↓`, `j`/`k`, `PgUp`/`PgDn`, `g`/`G`, wheel | scroll | +| Modal | `e` | open the file in the editor at the change line | +| Modal | `Esc` / click outside | close | + +## Error handling / edge cases + +- `load_full_change` fails (source missing/unreadable) → fall back to the + clipped `detail`; the modal still opens with what's available. +- Empty timeline / no focused entry → `Enter` is a no-op. +- `e` with no `editor_cmd` → `Modal::Error` (existing config-required behavior). +- Over/under-scroll → clamped (helper + renderer). +- Modal open while attached renders over the attached view (modal block is after + the view match) and is dismissible (existing handler). +- Workspace switch while a modal is open: existing modal lifecycle (the modal is + independent of the bar's per-frame state). + +## Testing + +- **`change_detail_lines`** (pure): Edit → all `-` lines blank-gutter, all `+` + lines numbered from `base_line`, no cap; Write → all content numbered; + `None` → empty. +- **`extract_change_events`** with `detail_max`: clipped vs `usize::MAX` full; + `index_in_line` assigned per emitted event (incl. MultiEdit → 0,1,…). +- **`load_full_change`** (round-trip): write a session line, build a + `ChangeEvent` with a matching `ChangeSource`, assert the full (un-clipped) + detail comes back; missing source → `None`. +- **`nav`** simplified: `Up`/`Down` clamp, `Top`/`Bottom`, `Enter` → `Open(sel)`, + `Esc` → `Exit`; `len == 0` no-op. +- **`clamp_scroll`** pure: top, bottom, mid, len < body. +- `entry_lines` reduced tests (header only, selected highlight). +- Modal render/input glue verified by build + manual (open, scroll all ways, + `e`, `Esc`). + +## Files touched + +- `src/activity/chronology.rs` — `ChangeSource`, `extract_change_events(detail_max)`, + `parse_file` source population, `load_full_change`. +- `src/ui/chronology_bar.rs` — `change_detail_lines`; `entry_lines` reduced to + header; `EntryHighlight` simplified. +- `src/ui/chronology_nav.rs` — single-level `nav`/`NavAction`; `ChronoSel`→index. +- `src/ui/attached.rs` — list-only bar render; `ChronologyHits` drops detail. +- `src/app.rs` — state changes (remove expanded/detail_rect; `sel: usize`). +- `src/ui/modal.rs` — `Modal::ChangeDetail` variant. +- `src/app/render.rs` — modal render; remove the bar's base_line/detail wiring. +- `src/app/input.rs` — bar `Open` → open modal; modal scroll/`e`/`Esc`/wheel. +- `README.md` — document the detail modal and its keys. From 5f7ca9c64bd425bb72360b3f55a420b521d335c3 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 18:18:43 -0400 Subject: [PATCH 55/69] docs: implementation plan for chronology detail modal --- .../2026-06-06-chronology-detail-modal.md | 621 ++++++++++++++++++ 1 file changed, 621 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-06-chronology-detail-modal.md diff --git a/docs/superpowers/plans/2026-06-06-chronology-detail-modal.md b/docs/superpowers/plans/2026-06-06-chronology-detail-modal.md new file mode 100644 index 0000000..36386cd --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-chronology-detail-modal.md @@ -0,0 +1,621 @@ +# Chronology Detail Modal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a selected chronology change's full diff in a large, scrollable modal overlay; reduce the docked bar to a list-only navigator that opens the modal. + +**Architecture:** Additive first — re-extract the full change on demand from the session log (`load_full_change`), a pure full-diff formatter (`change_detail_lines`), and a `Modal::ChangeDetail` overlay. Then one cohesive task flips the bar to list-only (simplified `nav`, `entry_lines`, render, state) and wires `Enter`/click to open the modal. + +**Tech Stack:** Rust, `ratatui`/`crossterm`, `serde_json`. `#[cfg(test)]` unit tests via `cargo test`. + +**Builds on (verified current code):** +- `chronology.rs`: `pub struct ChangeEvent { timestamp_ms, tool, file_path, summary, detail }`; `pub fn extract_change_events(v: &serde_json::Value) -> Vec` (clips old/new/content via `clip(s)` = `s.chars().take(DETAIL_MAX_CHARS=600)`); `parse_file(path)` parses each non-empty line via `serde_json` then `out.extend(extract_change_events(&v))`; `resolve_line_in_file(path, detail) -> u32`. +- `chronology_nav.rs`: `ChronoSel{Entry,Detail}`, `NavKey{Up,Down,Top,Bottom,Enter,Esc}`, `NavAction{None,Expand,Collapse,Open,Exit}`, `nav(sel, key, expanded, len)`, `adjust_scroll(scroll, sel_index, visible, len)`. +- `chronology_bar.rs`: `entry_lines(ev, worktree, expanded, width, base_line, highlight) -> Vec` (header + peek w/ gutter); `EntryHighlight{None,Header,Detail}`. +- `attached.rs`: `render_chronology_bar` loop computes `expanded`, `highlight`, `base_line`, calls `entry_lines`, records entry rects + `chronology_detail_rect`; `ChronologyHits{entries, detail, visible_entries}`; `ChronologyDraw{config,events,worktree,scroll,expanded,focused,sel}`. +- `app.rs`: `chronology_focused: bool`, `chronology_sel: ChronoSel`, `chronology_expanded: Option`, `chronology_detail_rect`, `chronology_scroll`, `chronology_visible_entries`, `chronology_entry_rects`, `chronology_bar_rect`, `chronology_last_workspace`; `reset_chronology_state_on_workspace_change(...)`. +- `input.rs`: chronology key block maps keys→`NavKey`, runs `nav`, applies `NavAction` (Expand/Collapse set `chronology_expanded`; Open → `open_focused_change` opens editor); mouse detail-click → `open_focused_change`, entry-click → select+expand. `open_focused_change(app, idx)` opens the editor via `editor_open_decision`/`open_in_editor_at`. +- `modal.rs`: `pub enum Modal { … Error{message}, … }`. `render.rs`: modal dispatch `if let Some(m) = &app.modal { match m { … } }` after the view match. `input.rs`: modal keys routed when `app.modal.is_some()`; `Modal::Error` dismissed on Esc/Enter. + +--- + +## File Structure + +- `src/activity/chronology.rs` — `ChangeSource`, `extract_change_events(detail_max)`, `parse_file` source population, `load_full_change`. +- `src/ui/chronology_bar.rs` — `change_detail_lines`; later `entry_lines` reduced; `EntryHighlight` removed. +- `src/ui/chronology_nav.rs` — later: single-level `nav`/`NavAction`, `ChronoSel`→index. +- `src/ui/modal.rs` — `Modal::ChangeDetail`. +- `src/app/render.rs` — modal render; later bar wiring. +- `src/app/input.rs` — modal scroll/`e`/`Esc`/wheel; later bar open→modal. +- `src/app.rs` — later state changes. +- `README.md`. + +--- + +## Task 1: Full-change re-extraction (data layer) + +**Files:** Modify `src/activity/chronology.rs` (+ fix `ChangeEvent {}` literals crate-wide). + +- [ ] **Step 1: Write the failing tests** + +Append to `chronology.rs`: + +```rust +#[cfg(test)] +mod source_tests { + use super::*; + use std::io::Write; + + #[test] + fn extract_assigns_index_in_line_and_respects_detail_max() { + let v: serde_json::Value = serde_json::from_str(r#"{"type":"assistant","timestamp":"2026-05-14T17:00:00.000Z","message":{"content":[{"type":"tool_use","name":"MultiEdit","input":{"file_path":"/wt/a.rs","edits":[{"old_string":"aaaa","new_string":"bbbb"},{"old_string":"cccc","new_string":"dddd"}]}}]}}"#).unwrap(); + let clipped = extract_change_events(&v, 2); + assert_eq!(clipped.len(), 2); + assert_eq!(clipped[0].source.index_in_line, 0); + assert_eq!(clipped[1].source.index_in_line, 1); + if let ChangeDetail::Edit { new, .. } = &clipped[0].detail { + assert_eq!(new, "bb", "detail_max=2 clips new_string"); + } else { + panic!("expected Edit"); + } + let full = extract_change_events(&v, usize::MAX); + if let ChangeDetail::Edit { new, .. } = &full[1].detail { + assert_eq!(new, "dddd", "usize::MAX keeps full text"); + } else { + panic!("expected Edit"); + } + } + + #[test] + fn load_full_change_round_trips_uncliped() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("s.jsonl"); + let mut f = std::fs::File::create(&path).unwrap(); + writeln!(f, "{{}}").unwrap(); // line 0: noise + writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-14T17:00:00.000Z","message":{{"content":[{{"type":"tool_use","name":"Edit","input":{{"file_path":"/wt/a.rs","old_string":"OLD","new_string":"A_VERY_LONG_NEW_STRING_BEYOND_ANY_CLIP"}}}}]}}}}"#).unwrap(); + let ev = ChangeEvent { + timestamp_ms: 0, + tool: ChangeTool::Edit, + file_path: PathBuf::from("/wt/a.rs"), + summary: String::new(), + detail: ChangeDetail::Edit { old: "OLD".into(), new: "A_VERY".into() }, + source: ChangeSource { session_file: path.clone(), line_index: 1, index_in_line: 0 }, + }; + let full = load_full_change(&ev).expect("re-extract"); + if let ChangeDetail::Edit { new, .. } = full { + assert_eq!(new, "A_VERY_LONG_NEW_STRING_BEYOND_ANY_CLIP"); + } else { + panic!("expected Edit"); + } + } + + #[test] + fn load_full_change_none_when_source_empty() { + let ev = ChangeEvent { + timestamp_ms: 0, + tool: ChangeTool::Write, + file_path: PathBuf::from("/wt/a.rs"), + summary: String::new(), + detail: ChangeDetail::Write { head: "x".into() }, + source: ChangeSource::default(), + }; + assert!(load_full_change(&ev).is_none()); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib source_tests` +Expected: FAIL — `ChangeSource` / arity of `extract_change_events` / `load_full_change` not defined. + +- [ ] **Step 3: Implement** + +In `chronology.rs`: + +1. Add the source type and field: +```rust +/// Where a `ChangeEvent` was extracted from, so the full (un-clipped) change +/// can be re-read on demand for the detail modal. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ChangeSource { + pub session_file: PathBuf, + pub line_index: usize, + pub index_in_line: usize, +} +``` +Add `pub source: ChangeSource,` to `struct ChangeEvent`. + +2. Change `clip` to take a max and thread `detail_max` through `extract_change_events`: +```rust +fn clip(s: &str, max: usize) -> String { + s.chars().take(max).collect() +} + +pub fn extract_change_events(v: &serde_json::Value, detail_max: usize) -> Vec { +``` +Inside, replace each `clip(x)` with `clip(x, detail_max)`. For EVERY `ChangeEvent { … }` literal built here, add: +```rust + source: ChangeSource { session_file: PathBuf::new(), line_index: 0, index_in_line: out.len() }, +``` +(`out.len()` is the event's position in this line's output — assign it in the literal BEFORE the `out.push(...)`. Since the literal is the argument to `push`, `out.len()` is evaluated before the push, giving 0,1,2… in order.) + +3. `parse_file` passes the clip and fills the source: +```rust +pub fn parse_file(path: &Path) -> Vec { + use std::io::{BufRead, BufReader}; + let Ok(file) = std::fs::File::open(path) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for (line_index, line) in BufReader::new(file).lines().map_while(|l| l.ok()).enumerate() { + if line.trim().is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(&line) { + for mut ev in extract_change_events(&v, DETAIL_MAX_CHARS) { + ev.source.session_file = path.to_path_buf(); + ev.source.line_index = line_index; + out.push(ev); + } + } + } + out +} +``` + +4. Add `load_full_change`: +```rust +/// Re-read the un-clipped change for `ev` from its session log. Returns `None` +/// when the source is empty/unreadable or the line/event is gone — callers fall +/// back to the event's clipped `detail`. +pub fn load_full_change(ev: &ChangeEvent) -> Option { + use std::io::{BufRead, BufReader}; + if ev.source.session_file.as_os_str().is_empty() { + return None; + } + let file = std::fs::File::open(&ev.source.session_file).ok()?; + let line = BufReader::new(file) + .lines() + .map_while(|l| l.ok()) + .nth(ev.source.line_index)?; + let v: serde_json::Value = serde_json::from_str(&line).ok()?; + let evs = extract_change_events(&v, usize::MAX); + evs.into_iter().nth(ev.source.index_in_line).map(|e| e.detail) +} +``` + +5. Update existing callers/literals so the crate compiles: + - In `chronology.rs`, the existing `extract_tests` call `extract_change_events(&v)` — change to `extract_change_events(&v, DETAIL_MAX_CHARS)` (or a small value where they assert clipping; they currently assert full short strings, so `DETAIL_MAX_CHARS` keeps them green). Any `ChangeEvent { … }` literal in `chronology.rs` tests gets `source: ChangeSource::default()`. + - `grep -rn "ChangeEvent {" src` and add `source: ChangeSource::default(),` to EVERY literal outside `extract_change_events` (e.g. `chronology_bar.rs` test `ev()` helper + its gutter tests; any `attached.rs`/`app.rs`/render tests). Import path: `crate::activity::chronology::ChangeSource` where needed. + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib source_tests` (3 pass), then `cargo test --lib` (full suite green), `cargo build` (zero warnings), `cargo fmt` + `cargo fmt --check`. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(chronology): re-extract full change on demand (ChangeSource + load_full_change)" +``` + +--- + +## Task 2: `change_detail_lines` full-diff formatter (pure) + +**Files:** Modify `src/ui/chronology_bar.rs`. + +- [ ] **Step 1: Write the failing tests** + +Add to `chronology_bar.rs` tests: + +```rust + #[test] + fn change_detail_lines_edit_full_no_cap() { + let detail = ChangeDetail::Edit { + old: "o1\no2\no3".into(), + new: "n1\nn2\nn3".into(), + }; + let lines = change_detail_lines(&detail, 10); + assert_eq!(lines.len(), 6, "all 3 old + 3 new, no take(2) cap"); + assert!(lines[0].starts_with(" - o1")); + assert_eq!(lines[3], " 10 + n1"); + assert_eq!(lines[5], " 12 + n3"); + } + + #[test] + fn change_detail_lines_write_numbers_all() { + let detail = ChangeDetail::Write { head: "a\nb".into() }; + let lines = change_detail_lines(&detail, 1); + assert_eq!(lines, vec![" 1 + a".to_string(), " 2 + b".to_string()]); + } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib change_detail_lines` +Expected: FAIL — function not defined. + +- [ ] **Step 3: Implement** + +Add to `chronology_bar.rs` (non-test): + +```rust +/// Full change as gutter-formatted display strings (no line cap — the modal +/// scrolls). Removed (`-`) lines get a blank gutter; added (`+`) lines are +/// numbered from `base_line`. +pub fn change_detail_lines(detail: &ChangeDetail, base_line: u32) -> Vec { + let mut out = Vec::new(); + match detail { + ChangeDetail::Edit { old, new } => { + for l in old.lines() { + out.push(format!(" - {l}")); + } + for (k, l) in new.lines().enumerate() { + let n = base_line.saturating_add(k as u32); + out.push(format!("{n:>4} + {l}")); + } + } + ChangeDetail::Write { head } => { + for (k, l) in head.lines().enumerate() { + let n = base_line.saturating_add(k as u32); + out.push(format!("{n:>4} + {l}")); + } + } + ChangeDetail::None => {} + } + out +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cargo test --lib change_detail_lines` (2 pass), `cargo build`, `cargo fmt --check`. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/chronology_bar.rs +git commit -m "feat(chronology): change_detail_lines full-diff formatter for the modal" +``` + +--- + +## Task 3: `Modal::ChangeDetail` — variant, scroll clamp, render, modal input + +**Files:** Modify `src/ui/modal.rs`, `src/app/render.rs`, `src/app/input.rs`. Add `clamp_scroll` to `src/ui/chronology_nav.rs`. + +The variant compiles unused this task (the bar wiring that opens it is Task 4). That's intentional. + +- [ ] **Step 1: Write the failing test (clamp_scroll)** + +Add to `chronology_nav.rs` tests: + +```rust + #[test] + fn clamp_scroll_bounds() { + // 100 lines, 20-row body → max top is 80 + assert_eq!(clamp_scroll(85, 100, 20), 80); + assert_eq!(clamp_scroll(5, 100, 20), 5); + // content shorter than body → no scroll + assert_eq!(clamp_scroll(7, 10, 20), 0); + } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cargo test --lib clamp_scroll` +Expected: FAIL — not defined. + +- [ ] **Step 3: Implement `clamp_scroll`** + +Add to `chronology_nav.rs` (non-test): + +```rust +/// Clamp a scroll offset so a `body`-row viewport never scrolls past the end of +/// `len` lines. Returns 0 when everything fits. +pub fn clamp_scroll(scroll: usize, len: usize, body: usize) -> usize { + let max = len.saturating_sub(body); + scroll.min(max) +} +``` + +- [ ] **Step 4: Add the `Modal::ChangeDetail` variant** + +In `src/ui/modal.rs`, add to `enum Modal`: + +```rust + /// Full diff of a chronology change, scrollable. + ChangeDetail { + title: String, + lines: Vec, + scroll: usize, + worktree: std::path::PathBuf, + file: std::path::PathBuf, + line: u32, + }, +``` + +If `modal.rs` has helper match arms (e.g. a function classifying modals or rendering a generic box), add a `Modal::ChangeDetail { .. }` arm consistent with the others (it renders via the dedicated function below, so a generic arm can return a placeholder title like `("change", title.clone())` if such a helper exists — match the file's pattern). + +- [ ] **Step 5: Render the modal** + +In `src/app/render.rs`, in the modal dispatch `match m { … }` (after the view match), add: + +```rust + crate::ui::modal::Modal::ChangeDetail { title, lines, scroll, .. } => { + render_change_detail_modal(f, area, title, lines, *scroll, &app.theme); + } +``` + +And add the renderer (near the other modal render helpers in `render.rs`): + +```rust +fn render_change_detail_modal( + f: &mut ratatui::Frame, + area: ratatui::layout::Rect, + title: &str, + lines: &[String], + scroll: usize, + theme: &crate::ui::theme::Theme, +) { + use ratatui::layout::Rect; + use ratatui::text::{Line, Span}; + use ratatui::widgets::{Block, Borders, Clear, Paragraph}; + // Centered box at ~90% of the screen. + let w = area.width.saturating_mul(9) / 10; + let h = area.height.saturating_mul(9) / 10; + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + let modal = Rect { x, y, width: w, height: h }; + f.render_widget(Clear, modal); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {title} ")) + .border_style(ratatui::style::Style::default().fg(theme.path)); + let inner = block.inner(modal); + f.render_widget(block, modal); + // Body: reserve the last row for a footer hint. + let body_h = inner.height.saturating_sub(1) as usize; + let scroll = crate::ui::chronology_nav::clamp_scroll(scroll, lines.len(), body_h); + let visible: Vec = lines + .iter() + .skip(scroll) + .take(body_h) + .map(|l| { + let clipped: String = l.chars().take(inner.width as usize).collect(); + Line::from(Span::raw(clipped)) + }) + .collect(); + let body_area = Rect { height: inner.height.saturating_sub(1), ..inner }; + f.render_widget(Paragraph::new(visible), body_area); + let end = (scroll + body_h).min(lines.len()); + let footer = format!( + "↑/↓ j/k PgUp/PgDn g/G · e editor · Esc close {}-{}/{}", + scroll + 1, + end, + lines.len() + ); + let footer_area = Rect { y: inner.y + inner.height.saturating_sub(1), height: 1, ..inner }; + f.render_widget( + Paragraph::new(Line::from(Span::styled( + footer.chars().take(inner.width as usize).collect::(), + ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::DIM), + ))), + footer_area, + ); +} +``` +(Adapt `theme.path`/`theme.header_style()` to the file's existing modal styling conventions; match how other modal helpers obtain colors.) + +- [ ] **Step 6: Modal input handling** + +In `src/app/input.rs`, find the modal key handler (where `Modal::Error`/`Modal::UpdatesPanel` etc. are matched while `app.modal.is_some()`). Add a `Modal::ChangeDetail` arm. Because the variant fields are owned, mutate via a `match &mut app.modal`: + +```rust + Some(crate::ui::modal::Modal::ChangeDetail { lines, scroll, worktree, file, line, .. }) => { + const PAGE: usize = 10; + let len = lines.len(); + match k.code { + KeyCode::Down | KeyCode::Char('j') => *scroll = scroll.saturating_add(1).min(len.saturating_sub(1)), + KeyCode::Up | KeyCode::Char('k') => *scroll = scroll.saturating_sub(1), + KeyCode::PageDown => *scroll = scroll.saturating_add(PAGE).min(len.saturating_sub(1)), + KeyCode::PageUp => *scroll = scroll.saturating_sub(PAGE), + KeyCode::Char('g') => *scroll = 0, + KeyCode::Char('G') => *scroll = len.saturating_sub(1), + KeyCode::Esc => { app.modal = None; } + KeyCode::Char('e') => { + let (worktree, file, line) = (worktree.clone(), file.clone(), *line); + open_change_in_editor(app, &worktree, &file, line); + } + _ => {} + } + return Ok(()); + } +``` +(Match the actual structure of the modal handler — it may be a `match app.modal { … }` or a helper `handle_key_modal`. Place this arm consistently. The renderer re-clamps `scroll` against the real body height, so the coarse `len-1` clamp here is safe.) + +Add `open_change_in_editor` (reuse the existing editor decision/launch, factored from `open_focused_change`): + +```rust +/// Open `file` at `line` using the configured editor, surfacing a Modal::Error +/// when unset or on failure. (Shared by the detail modal's `e`.) +fn open_change_in_editor(app: &mut App, worktree: &Path, file: &Path, line: u32) { + use crate::commands::external::{EditorOpenDecision, editor_open_decision}; + let editor_cmd = app.store.get_setting("editor_cmd").ok().flatten(); + match editor_open_decision(editor_cmd.as_deref()) { + EditorOpenDecision::NeedsConfig => { + app.modal = Some(crate::ui::modal::Modal::Error { + message: "No editor_cmd configured. Set one to open changes in your \ + editor, e.g.\n wsx config set editor_cmd 'alacritty -e nvim'" + .to_string(), + }); + } + EditorOpenDecision::Launch(cmd) => { + if let Err(e) = + crate::commands::external::open_in_editor_at(worktree, file, line, Some(&cmd)) + { + app.modal = Some(crate::ui::modal::Modal::Error { + message: format!("Failed to open editor: {e}"), + }); + } + } + } +} +``` + +- [ ] **Step 7: Mouse wheel + click-outside** + +In `handle_mouse`, before the existing chronology-bar wheel block, add: if `matches!(app.modal, Some(Modal::ChangeDetail{..}))` and the event is a wheel, adjust `scroll` (same +1/-1 logic via `match &mut app.modal`) and return; if it's a left-click outside the modal box, `app.modal = None`. (A simple approach: any left-click while `Modal::ChangeDetail` is open closes it — modal is a focused overlay. Keep it minimal: wheel scrolls, click closes.) + +- [ ] **Step 8: Verify** + +Run: `cargo test --lib clamp_scroll` (pass), `cargo test --lib` (no regressions), `cargo build` (zero warnings — the variant is constructed in Task 4; until then it's only matched, which is fine; if an "unused variant" or unreachable warning appears, it won't because all arms handle it — confirm), `cargo fmt --check`. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat(chronology): ChangeDetail modal (variant, render, scroll input, e to open)" +``` + +--- + +## Task 4: Bar → list-only + open the modal + +**Files:** Modify `src/ui/chronology_nav.rs`, `src/ui/chronology_bar.rs`, `src/ui/attached.rs`, `src/app.rs`, `src/app/render.rs`, `src/app/input.rs`. This is one cohesive task (the pieces reference each other); land it together so the tree compiles. + +- [ ] **Step 1: Simplify the nav reducer** + +In `chronology_nav.rs`: delete `ChronoSel` (and its `Default`/`index`); `NavAction` becomes `{ None, Open(usize), Exit }` (remove `Expand`/`Collapse`); rewrite `nav` to a single-level index machine: + +```rust +pub fn nav(sel: usize, key: NavKey, len: usize) -> (usize, NavAction) { + if key == NavKey::Esc { + return (sel, NavAction::Exit); + } + if len == 0 { + return (sel, NavAction::None); + } + let last = len - 1; + match key { + NavKey::Down => ((sel + 1).min(last), NavAction::None), + NavKey::Up => (sel.saturating_sub(1), NavAction::None), + NavKey::Top => (0, NavAction::None), + NavKey::Bottom => (last, NavAction::None), + NavKey::Enter => (sel, NavAction::Open(sel)), + NavKey::Esc => unreachable!(), + } +} +``` +Rewrite the reducer tests in this module accordingly: `Up`/`Down` clamp, `Top`/`Bottom`, `Enter`→`Open(sel)`, `Esc`→`Exit`, `len==0` no-op. Keep `adjust_scroll`/`clamp_scroll` and their tests. + +- [ ] **Step 2: Reduce `entry_lines` to header-only** + +In `chronology_bar.rs`: remove the `EntryHighlight` enum and the peek/gutter/`base_line`/`expanded` logic. New signature + body: + +```rust +/// One bar row: `HH:MM `, reversed when `selected`. +pub fn entry_lines(ev: &ChangeEvent, worktree: &Path, width: u16, selected: bool) -> Vec> { + let rel = relative_display(&ev.file_path, worktree); + let path_budget = (width as usize).saturating_sub(6); + let path = abbreviate_path(&rel, path_budget); + let base = Style::default(); + let style = if selected { base.add_modifier(Modifier::REVERSED) } else { base }; + let time_style = if selected { + Style::default().add_modifier(Modifier::REVERSED | Modifier::DIM) + } else { + Style::default().add_modifier(Modifier::DIM) + }; + vec![Line::from(vec![ + Span::styled(hhmm(ev.timestamp_ms), time_style), + Span::styled(" ", style), + Span::styled(path, style), + ])] +} +``` +Update/trim the chronology_bar tests that referenced the old peek/highlight/`base_line` (keep `relative_path_*`, `auto_hide_*`, `abbreviate_*`, `change_detail_lines_*`; replace the entry/peek/highlight tests with a couple asserting the single header line and that `selected` reverses it). Keep `change_detail_lines` (Task 2) intact. + +- [ ] **Step 3: `render_chronology_bar` list-only** + +In `attached.rs`: `ChronologyDraw` drops `expanded` and `sel: ChronoSel`, gains `sel: usize`. `ChronologyHits` drops `detail`; keep `entries`, `visible_entries`. In the loop, remove the `expanded`/`base_line`/`EntryHighlight`/detail-rect logic; compute `let selected = draw.focused && i == draw.sel;` and call `entry_lines(ev, draw.worktree, inner_width, selected)`. Record entry rects + `visible_entries` as before. Update `PanesDrawOutput` to drop `chronology_detail_rect`. + +- [ ] **Step 4: App state** + +In `app.rs`: remove `chronology_expanded` and `chronology_detail_rect` (and their inits + the `render.rs` clear of `chronology_detail_rect`). Change `chronology_sel: ChronoSel` → `chronology_sel: usize` (init `0`). Update `reset_chronology_state_on_workspace_change` to take/reset `chronology_sel: &mut usize` (`*sel = 0`) and `chronology_focused: &mut bool` (drop the `expanded`/`ChronoSel` params); update its call site in `render.rs`. + +- [ ] **Step 5: render.rs bar wiring** + +In `render.rs` `View::Attached`: build `ChronologyDraw { …, sel: app.chronology_sel }` (no `expanded`); drop the `app.chronology_detail_rect = …` store and the `chronology_detail_rect` clear; keep `chronology_entry_rects`/`chronology_visible_entries`. Remove the `base_line`/`resolve_line_in_file` call added for the bar peek (the modal computes its own line on open). + +- [ ] **Step 6: input.rs — open the modal** + +Replace the chronology key block's `nav` usage and `NavAction` handling: map keys→`NavKey`, call `nav(app.chronology_sel, navkey, len)`, store `app.chronology_sel`, and match `NavAction`: +- `None` → nothing; `Exit` → `app.chronology_focused = false`; +- `Open(i)` → `open_change_modal(app, i)`. +Remove `Expand`/`Collapse`/`Detail` handling. In `handle_mouse`, the entry-click branch → `open_change_modal(app, idx)` (and set `chronology_focused = true`, `chronology_sel = idx`); remove the detail-rect click branch. Repurpose/replace `open_focused_change` with: + +```rust +/// Open the chronology entry at `idx` in the full-change detail modal. +fn open_change_modal(app: &mut App, idx: usize) { + let Some((_ws_id, worktree)) = focused_attached_workspace(app) else { return }; + let Some(ev) = focused_attached_workspace(app) + .and_then(|(ws_id, _)| app.chronology.get(&ws_id)) + .and_then(|t| t.events().get(idx).cloned()) + else { return }; + let detail = crate::activity::chronology::load_full_change(&ev).unwrap_or(ev.detail.clone()); + let line = crate::activity::chronology::resolve_line_in_file(&ev.file_path, &detail); + let lines = crate::ui::chronology_bar::change_detail_lines(&detail, line); + let rel = crate::ui::chronology_bar::relative_display(&ev.file_path, &worktree); + let title = format!("{} {}", crate::ui::chronology_bar::hhmm_pub(ev.timestamp_ms), rel); + app.modal = Some(crate::ui::modal::Modal::ChangeDetail { + title, + lines, + scroll: 0, + worktree, + file: ev.file_path.clone(), + line, + }); +} +``` +NOTE: `hhmm` is private in `chronology_bar.rs`; expose a `pub fn hhmm_pub(ms: i64) -> String` (or make `hhmm` pub) for the title, OR format the time inline. `relative_display` is already `pub`. `Timeline::events()` returns `&[ChangeEvent]`; `.get(idx).cloned()` needs `ChangeEvent: Clone` (it derives Clone). Resolve the borrow by cloning `ev` before mutating `app.modal` (shown above). + +- [ ] **Step 7: Verify** + +Run: `cargo test --lib` (all pass — nav/entry_lines/clamp_scroll/change_detail_lines/source), `cargo build` (zero warnings), `cargo clippy --lib` (no new lints), `cargo fmt --check` (clean). +Manual: focus the bar (`Ctrl-x`+arrow), move with `j`/`k`, `Enter` → the modal opens with the full diff + line-number gutter; scroll with arrows/`j`/`k`/`PgUp`/`PgDn`/`g`/`G`/wheel; `e` opens the editor at the line; `Esc`/click closes. Click a bar entry → modal opens. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "feat(chronology): list-only bar that opens the full-change detail modal" +``` + +--- + +## Task 5: README + +**Files:** Modify `README.md`. + +- [ ] **Step 1: Update the Change chronology section** + +Replace the inline-peek / in-bar-expand / two-step-open prose with: the bar is the time-ordered list; `Enter` (or click) opens the selected change in a **scrollable modal** showing the full diff with a line-number gutter; in the modal, `↑/↓ j/k PgUp/PgDn g/G` and the wheel scroll, `e` opens the file in your editor at the change line, `Esc` (or click outside) closes. Remove now-inaccurate mentions of the inline diff peek / in-bar gutter / expand-collapse. Match the README's prose style. + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: document the chronology full-change detail modal" +``` + +--- + +## Self-Review (completed during planning) + +**Spec coverage:** full-change re-extraction (`ChangeSource`/`load_full_change`, T1) ✓; full-diff formatter (`change_detail_lines`, T2) ✓; modal variant + render + scroll (kbd+wheel) + `e` + `Esc` (T3) ✓; bar list-only + simplified nav/state + open-modal wiring (T4) ✓; README (T5) ✓; removals (peek/expand/two-level/gutter-in-bar/detail-rect) enacted in T4 ✓; clamp helper tested (T3) ✓. + +**Placeholder scan:** code shown for each step; the few "match the file's pattern / adapt theme" notes point at concrete existing conventions, not deferred work. T1 and T4 explicitly enumerate the cross-file literal/signature fixups required to keep the tree compiling. + +**Type consistency:** `ChangeSource{session_file,line_index,index_in_line}`, `extract_change_events(v, detail_max)`, `load_full_change(&ChangeEvent)->Option`, `change_detail_lines(&ChangeDetail, base_line:u32)->Vec`, `clamp_scroll(scroll,len,body)`, `nav(sel:usize,key,len)->(usize,NavAction{None,Open,Exit})`, `entry_lines(ev,worktree,width,selected:bool)`, `ChronologyDraw{…,sel:usize}` (no `expanded`), `ChronologyHits{entries,visible_entries}` (no `detail`), `Modal::ChangeDetail{title,lines,scroll,worktree,file,line}`, `open_change_modal`, `open_change_in_editor` — consistent across tasks. From be967298225246b5398fd51637ebe0645a334ddc Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 18:23:10 -0400 Subject: [PATCH 56/69] feat(chronology): re-extract full change on demand (ChangeSource + load_full_change) Add ChangeSource back-reference on ChangeEvent (session_file, line_index, index_in_line), thread detail_max through extract_change_events so clip is parameterised, populate source in parse_file, and expose load_full_change for on-demand unclipped re-extraction from the session log. Fix all ChangeEvent literals and extract_change_events call-sites across the codebase to compile cleanly. --- src/activity/chronology.rs | 155 +++++++++++++++++++++++++++++++++---- src/ui/chronology_bar.rs | 5 +- 2 files changed, 144 insertions(+), 16 deletions(-) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index b7c8546..9bdf7ae 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -43,6 +43,15 @@ pub enum ChangeDetail { None, } +/// Where a `ChangeEvent` was extracted from, so the full (un-clipped) change can +/// be re-read on demand for the detail modal. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ChangeSource { + pub session_file: PathBuf, + pub line_index: usize, + pub index_in_line: usize, +} + /// One change the agent made at one moment in time. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ChangeEvent { @@ -55,6 +64,8 @@ pub struct ChangeEvent { pub summary: String, /// Change text for the C-expand peek. pub detail: ChangeDetail, + /// Back-reference to the session log line for full re-extraction. + pub source: ChangeSource, } pub(crate) const SUMMARY_MAX_CHARS: usize = 80; @@ -103,8 +114,8 @@ pub(crate) fn summarize_edit(old: &str, new: &str) -> String { /// Bounded number of characters retained per side of a diff peek. const DETAIL_MAX_CHARS: usize = 600; -fn clip(s: &str) -> String { - s.chars().take(DETAIL_MAX_CHARS).collect() +fn clip(s: &str, max: usize) -> String { + s.chars().take(max).collect() } fn tool_from_name(name: &str) -> Option { @@ -120,7 +131,9 @@ fn tool_from_name(name: &str) -> Option { /// Extract zero or more `ChangeEvent`s from one parsed Claude JSONL line. /// Only `type == "assistant"` lines with mutating `tool_use` blocks produce /// events. A `MultiEdit` produces one event per element of its `edits` array. -pub fn extract_change_events(v: &serde_json::Value) -> Vec { +/// `detail_max` caps the chars retained per diff side; pass `DETAIL_MAX_CHARS` +/// for normal use or `usize::MAX` for full unclipped re-extraction. +pub fn extract_change_events(v: &serde_json::Value, detail_max: usize) -> Vec { let mut out = Vec::new(); if v.get("type").and_then(|t| t.as_str()) != Some("assistant") { return out; @@ -162,7 +175,12 @@ pub fn extract_change_events(v: &serde_json::Value) -> Vec { file_path, summary: summarize_write(content), detail: ChangeDetail::Write { - head: clip(content), + head: clip(content, detail_max), + }, + source: ChangeSource { + session_file: PathBuf::new(), + line_index: 0, + index_in_line: out.len(), }, }); } @@ -178,8 +196,13 @@ pub fn extract_change_events(v: &serde_json::Value) -> Vec { file_path: file_path.clone(), summary: summarize_edit(old, new), detail: ChangeDetail::Edit { - old: clip(old), - new: clip(new), + old: clip(old, detail_max), + new: clip(new, detail_max), + }, + source: ChangeSource { + session_file: PathBuf::new(), + line_index: 0, + index_in_line: out.len(), }, }); } @@ -201,8 +224,13 @@ pub fn extract_change_events(v: &serde_json::Value) -> Vec { file_path, summary: summarize_edit(old, new), detail: ChangeDetail::Edit { - old: clip(old), - new: clip(new), + old: clip(old, detail_max), + new: clip(new, detail_max), + }, + source: ChangeSource { + session_file: PathBuf::new(), + line_index: 0, + index_in_line: out.len(), }, }); } @@ -232,7 +260,7 @@ mod extract_tests { let v = line( r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Edit","input":{"file_path":"/wt/a.rs","old_string":"let x=1;","new_string":"pub fn foo() {}"}}]}}"#, ); - let evs = extract_change_events(&v); + let evs = extract_change_events(&v, DETAIL_MAX_CHARS); assert_eq!(evs.len(), 1); assert_eq!(evs[0].tool, ChangeTool::Edit); assert_eq!(evs[0].file_path, std::path::PathBuf::from("/wt/a.rs")); @@ -249,7 +277,7 @@ mod extract_tests { let v = line( r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t2","name":"Write","input":{"file_path":"/wt/new.rs","content":"pub struct Z;"}}]}}"#, ); - let evs = extract_change_events(&v); + let evs = extract_change_events(&v, DETAIL_MAX_CHARS); assert_eq!(evs.len(), 1); assert_eq!(evs[0].tool, ChangeTool::Write); assert_eq!(evs[0].summary, "pub struct Z;"); @@ -263,7 +291,7 @@ mod extract_tests { let v = line( r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t3","name":"MultiEdit","input":{"file_path":"/wt/a.rs","edits":[{"old_string":"a","new_string":"pub fn one(){}"},{"old_string":"b","new_string":"pub fn two(){}"}]}}]}}"#, ); - let evs = extract_change_events(&v); + let evs = extract_change_events(&v, DETAIL_MAX_CHARS); assert_eq!(evs.len(), 2); assert_eq!(evs[0].tool, ChangeTool::MultiEdit); assert_eq!(evs[1].summary, "pub fn two(){}"); @@ -274,7 +302,7 @@ mod extract_tests { let v = line( r#"{"type":"assistant","timestamp":"2026-05-14T17:32:02.744Z","message":{"content":[{"type":"tool_use","id":"t4","name":"Read","input":{"file_path":"/wt/a.rs"}},{"type":"tool_use","id":"t5","name":"Bash","input":{"command":"ls"}}]}}"#, ); - assert!(extract_change_events(&v).is_empty()); + assert!(extract_change_events(&v, DETAIL_MAX_CHARS).is_empty()); } #[test] @@ -282,7 +310,7 @@ mod extract_tests { let v = line( r#"{"type":"user","timestamp":"2026-05-14T17:32:02.744Z","message":{"role":"user","content":"hi"}}"#, ); - assert!(extract_change_events(&v).is_empty()); + assert!(extract_change_events(&v, DETAIL_MAX_CHARS).is_empty()); } } @@ -360,17 +388,45 @@ pub fn parse_file(path: &Path) -> Vec { return Vec::new(); }; let mut out = Vec::new(); - for line in BufReader::new(file).lines().map_while(|l| l.ok()) { + for (line_index, line) in BufReader::new(file) + .lines() + .map_while(|l| l.ok()) + .enumerate() + { if line.trim().is_empty() { continue; } if let Ok(v) = serde_json::from_str::(&line) { - out.extend(extract_change_events(&v)); + for mut ev in extract_change_events(&v, DETAIL_MAX_CHARS) { + ev.source.session_file = path.to_path_buf(); + ev.source.line_index = line_index; + out.push(ev); + } } } out } +/// Re-read the un-clipped change for `ev` from its session log. `None` when the +/// source is empty/unreadable or the line/event is gone (callers fall back to +/// the clipped `detail`). +pub fn load_full_change(ev: &ChangeEvent) -> Option { + use std::io::{BufRead, BufReader}; + if ev.source.session_file.as_os_str().is_empty() { + return None; + } + let file = std::fs::File::open(&ev.source.session_file).ok()?; + let line = BufReader::new(file) + .lines() + .map_while(|l| l.ok()) + .nth(ev.source.line_index)?; + let v: serde_json::Value = serde_json::from_str(&line).ok()?; + let evs = extract_change_events(&v, usize::MAX); + evs.into_iter() + .nth(ev.source.index_in_line) + .map(|e| e.detail) +} + /// All `.jsonl` session files under `/.claude/projects//`. /// Testable variant taking an explicit home dir and canonical worktree path. pub(crate) fn session_files_in(home: &Path, abs_worktree: &Path) -> Vec { @@ -636,3 +692,72 @@ mod timeline_tests { assert_eq!(evs[0].file_path, PathBuf::from("/wt/a.rs")); } } + +#[cfg(test)] +mod source_tests { + use super::*; + use std::io::Write; + + #[test] + fn extract_assigns_index_in_line_and_respects_detail_max() { + let v: serde_json::Value = serde_json::from_str(r#"{"type":"assistant","timestamp":"2026-05-14T17:00:00.000Z","message":{"content":[{"type":"tool_use","name":"MultiEdit","input":{"file_path":"/wt/a.rs","edits":[{"old_string":"aaaa","new_string":"bbbb"},{"old_string":"cccc","new_string":"dddd"}]}}]}}"#).unwrap(); + let clipped = extract_change_events(&v, 2); + assert_eq!(clipped.len(), 2); + assert_eq!(clipped[0].source.index_in_line, 0); + assert_eq!(clipped[1].source.index_in_line, 1); + if let ChangeDetail::Edit { new, .. } = &clipped[0].detail { + assert_eq!(new, "bb", "detail_max=2 clips new_string"); + } else { + panic!("expected Edit"); + } + let full = extract_change_events(&v, usize::MAX); + if let ChangeDetail::Edit { new, .. } = &full[1].detail { + assert_eq!(new, "dddd", "usize::MAX keeps full text"); + } else { + panic!("expected Edit"); + } + } + + #[test] + fn load_full_change_round_trips_uncliped() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("s.jsonl"); + let mut f = std::fs::File::create(&path).unwrap(); + writeln!(f, "{{}}").unwrap(); + writeln!(f, r#"{{"type":"assistant","timestamp":"2026-05-14T17:00:00.000Z","message":{{"content":[{{"type":"tool_use","name":"Edit","input":{{"file_path":"/wt/a.rs","old_string":"OLD","new_string":"A_VERY_LONG_NEW_STRING_BEYOND_ANY_CLIP"}}}}]}}}}"#).unwrap(); + let ev = ChangeEvent { + timestamp_ms: 0, + tool: ChangeTool::Edit, + file_path: PathBuf::from("/wt/a.rs"), + summary: String::new(), + detail: ChangeDetail::Edit { + old: "OLD".into(), + new: "A_VERY".into(), + }, + source: ChangeSource { + session_file: path.clone(), + line_index: 1, + index_in_line: 0, + }, + }; + let full = load_full_change(&ev).expect("re-extract"); + if let ChangeDetail::Edit { new, .. } = full { + assert_eq!(new, "A_VERY_LONG_NEW_STRING_BEYOND_ANY_CLIP"); + } else { + panic!("expected Edit"); + } + } + + #[test] + fn load_full_change_none_when_source_empty() { + let ev = ChangeEvent { + timestamp_ms: 0, + tool: ChangeTool::Write, + file_path: PathBuf::from("/wt/a.rs"), + summary: String::new(), + detail: ChangeDetail::Write { head: "x".into() }, + source: ChangeSource::default(), + }; + assert!(load_full_change(&ev).is_none()); + } +} diff --git a/src/ui/chronology_bar.rs b/src/ui/chronology_bar.rs index e3aea5d..54a5987 100644 --- a/src/ui/chronology_bar.rs +++ b/src/ui/chronology_bar.rs @@ -178,7 +178,7 @@ pub fn entry_lines( #[cfg(test)] mod tests { use super::*; - use crate::activity::chronology::ChangeTool; + use crate::activity::chronology::{ChangeSource, ChangeTool}; use std::path::PathBuf; fn ev(file: &str, summary: &str) -> ChangeEvent { @@ -191,6 +191,7 @@ mod tests { old: "a".into(), new: "b".into(), }, + source: ChangeSource::default(), } } @@ -350,6 +351,7 @@ mod tests { old: "old0\nold1".into(), new: "new0\nnew1".into(), }, + source: ChangeSource::default(), }; let lines = entry_lines(&ev, Path::new("/wt"), true, 60, 42, EntryHighlight::None); let texts: Vec = lines.iter().map(line_text).collect(); @@ -382,6 +384,7 @@ mod tests { detail: ChangeDetail::Write { head: "l1\nl2\nl3".into(), }, + source: ChangeSource::default(), }; let lines = entry_lines(&ev, Path::new("/wt"), true, 60, 1, EntryHighlight::None); let texts: Vec = lines.iter().map(line_text).collect(); From c98d8b133e26336a13c69e22022b9053b3aecf9c Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 18:26:46 -0400 Subject: [PATCH 57/69] feat(chronology): change_detail_lines full-diff formatter for the modal --- src/ui/chronology_bar.rs | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/ui/chronology_bar.rs b/src/ui/chronology_bar.rs index 54a5987..526b627 100644 --- a/src/ui/chronology_bar.rs +++ b/src/ui/chronology_bar.rs @@ -175,6 +175,32 @@ pub fn entry_lines( out } +/// Full change as gutter-formatted display strings (no line cap — the modal +/// scrolls). Removed (`-`) lines get a 5-space blank gutter; added (`+`) lines +/// are numbered from `base_line` (4-wide right-aligned). +pub fn change_detail_lines(detail: &ChangeDetail, base_line: u32) -> Vec { + let mut out = Vec::new(); + match detail { + ChangeDetail::Edit { old, new } => { + for l in old.lines() { + out.push(format!(" - {l}")); + } + for (k, l) in new.lines().enumerate() { + let n = base_line.saturating_add(k as u32); + out.push(format!("{n:>4} + {l}")); + } + } + ChangeDetail::Write { head } => { + for (k, l) in head.lines().enumerate() { + let n = base_line.saturating_add(k as u32); + out.push(format!("{n:>4} + {l}")); + } + } + ChangeDetail::None => {} + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -374,6 +400,33 @@ mod tests { ); } + #[test] + fn change_detail_lines_edit_full_no_cap() { + let detail = ChangeDetail::Edit { + old: "o1\no2\no3".into(), + new: "n1\nn2\nn3".into(), + }; + let lines = change_detail_lines(&detail, 10); + assert_eq!(lines.len(), 6, "all 3 old + 3 new, no take(2) cap"); + assert!(lines[0].starts_with(" - o1"), "{:?}", lines[0]); + assert_eq!(lines[3], " 10 + n1"); + assert_eq!(lines[5], " 12 + n3"); + } + + #[test] + fn change_detail_lines_write_numbers_all() { + let detail = ChangeDetail::Write { + head: "a\nb".into(), + }; + let lines = change_detail_lines(&detail, 1); + assert_eq!(lines, vec![" 1 + a".to_string(), " 2 + b".to_string()]); + } + + #[test] + fn change_detail_lines_none_is_empty() { + assert!(change_detail_lines(&ChangeDetail::None, 1).is_empty()); + } + #[test] fn write_peek_numbers_from_base_line() { let ev = ChangeEvent { From f9158c2516ea0ff862d31e37ae237d7bd43dfbf1 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 18:31:45 -0400 Subject: [PATCH 58/69] feat(chronology): ChangeDetail modal (variant, render, scroll input, e to open) --- src/app/input.rs | 138 +++++++++++++++++++++++++++++++++++++++ src/app/render.rs | 77 ++++++++++++++++++++++ src/ui/chronology_nav.rs | 19 ++++++ src/ui/modal.rs | 11 ++++ 4 files changed, 245 insertions(+) diff --git a/src/app/input.rs b/src/app/input.rs index 479f906..182d7b8 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -328,6 +328,36 @@ fn open_focused_change(app: &mut App, idx: usize) { } } +/// Open `file` at `line` using the configured editor, surfacing a Modal::Error +/// when unset or on failure. Shared by the detail modal's `e`. +fn open_change_in_editor( + app: &mut App, + worktree: &std::path::Path, + file: &std::path::Path, + line: u32, +) { + use crate::commands::external::{EditorOpenDecision, editor_open_decision}; + let editor_cmd = app.store.get_setting("editor_cmd").ok().flatten(); + match editor_open_decision(editor_cmd.as_deref()) { + EditorOpenDecision::NeedsConfig => { + app.modal = Some(crate::ui::modal::Modal::Error { + message: "No editor_cmd configured. Set one to open changes in your \ + editor, e.g.\n wsx config set editor_cmd 'alacritty -e nvim'" + .to_string(), + }); + } + EditorOpenDecision::Launch(cmd) => { + if let Err(e) = + crate::commands::external::open_in_editor_at(worktree, file, line, Some(&cmd)) + { + app.modal = Some(crate::ui::modal::Modal::Error { + message: format!("Failed to open editor: {e}"), + }); + } + } + } +} + /// Resolve the configured chronology side for the focused attached workspace. fn focused_chronology_side(app: &App) -> Option { let crate::ui::View::Attached(state) = &app.view else { @@ -1656,6 +1686,92 @@ async fn handle_key_modal( } _ => {} }, + Modal::ChangeDetail { + lines, + mut scroll, + worktree, + file, + line, + title, + } => { + const PAGE: usize = 10; + let len = lines.len(); + match k.code { + KeyCode::Down | KeyCode::Char('j') => { + scroll = scroll.saturating_add(1).min(len.saturating_sub(1)); + app.modal = Some(Modal::ChangeDetail { + title, + lines, + scroll, + worktree, + file, + line, + }); + } + KeyCode::Up | KeyCode::Char('k') => { + scroll = scroll.saturating_sub(1); + app.modal = Some(Modal::ChangeDetail { + title, + lines, + scroll, + worktree, + file, + line, + }); + } + KeyCode::PageDown => { + scroll = scroll.saturating_add(PAGE).min(len.saturating_sub(1)); + app.modal = Some(Modal::ChangeDetail { + title, + lines, + scroll, + worktree, + file, + line, + }); + } + KeyCode::PageUp => { + scroll = scroll.saturating_sub(PAGE); + app.modal = Some(Modal::ChangeDetail { + title, + lines, + scroll, + worktree, + file, + line, + }); + } + KeyCode::Char('g') => { + scroll = 0; + app.modal = Some(Modal::ChangeDetail { + title, + lines, + scroll, + worktree, + file, + line, + }); + } + KeyCode::Char('G') => { + scroll = len.saturating_sub(1); + app.modal = Some(Modal::ChangeDetail { + title, + lines, + scroll, + worktree, + file, + line, + }); + } + KeyCode::Esc => { + app.modal = None; + } + KeyCode::Char('e') => { + open_change_in_editor(app, &worktree, &file, line); + } + _ => {} + } + } } Ok(()) } @@ -1844,6 +1960,28 @@ async fn handle_mouse(app: &mut App, m: MouseEvent) { } } + // ChangeDetail modal: wheel scrolls the overlay; left-click closes it. + if matches!(app.modal, Some(Modal::ChangeDetail { .. })) { + match m.kind { + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + if let Some(Modal::ChangeDetail { lines, scroll, .. }) = &mut app.modal { + let len = lines.len(); + if matches!(m.kind, MouseEventKind::ScrollDown) { + *scroll = scroll.saturating_add(1).min(len.saturating_sub(1)); + } else { + *scroll = scroll.saturating_sub(1); + } + } + return; + } + MouseEventKind::Down(MouseButton::Left) => { + app.modal = None; + return; + } + _ => {} + } + } + // Change-chronology bar: a wheel over the bar scrolls the chronology // entries rather than the PTY/scrollback. Checked before the pane-scroll // block below so a wheel inside the bar returns early. diff --git a/src/app/render.rs b/src/app/render.rs index 104048b..9e531ce 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -838,6 +838,14 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { crate::ui::modal::Modal::UsageWindowPicker { .. } => { // Rendered separately below, anchored to the footer graph. } + crate::ui::modal::Modal::ChangeDetail { + title, + lines, + scroll, + .. + } => { + render_change_detail_modal(f, area, title, lines, *scroll, &app.theme); + } other => modal::render(f, area, other, app.tick, &app.theme), } } @@ -1020,6 +1028,75 @@ pub(crate) fn translate_activity(a: ActivityState) -> crate::ui::updates_bar::Ac } } +fn render_change_detail_modal( + f: &mut ratatui::Frame, + area: ratatui::layout::Rect, + title: &str, + lines: &[String], + scroll: usize, + theme: &crate::ui::theme::Theme, +) { + use ratatui::layout::Rect; + use ratatui::text::{Line, Span}; + use ratatui::widgets::{Block, Borders, Clear, Paragraph}; + let w = area.width.saturating_mul(9) / 10; + let h = area.height.saturating_mul(9) / 10; + let x = area.x + area.width.saturating_sub(w) / 2; + let y = area.y + area.height.saturating_sub(h) / 2; + let modal = Rect { + x, + y, + width: w, + height: h, + }; + f.render_widget(Clear, modal); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {title} ")) + .border_style(ratatui::style::Style::default().fg(theme.path)); + let inner = block.inner(modal); + f.render_widget(block, modal); + let body_h = inner.height.saturating_sub(1) as usize; + let scroll = crate::ui::chronology_nav::clamp_scroll(scroll, lines.len(), body_h); + let visible: Vec = lines + .iter() + .skip(scroll) + .take(body_h) + .map(|l| { + Line::from(Span::raw( + l.chars().take(inner.width as usize).collect::(), + )) + }) + .collect(); + let body_area = Rect { + height: inner.height.saturating_sub(1), + ..inner + }; + f.render_widget(Paragraph::new(visible), body_area); + let end = (scroll + body_h).min(lines.len()); + let footer = format!( + "↑/↓ j/k PgUp/PgDn g/G · e editor · Esc close {}-{}/{}", + scroll + 1, + end, + lines.len() + ); + let footer_area = Rect { + y: inner.y + inner.height.saturating_sub(1), + height: 1, + ..inner + }; + f.render_widget( + Paragraph::new(Line::from(Span::styled( + footer + .chars() + .take(inner.width as usize) + .collect::(), + ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::DIM), + ))), + footer_area, + ); +} + #[cfg(test)] mod layout_indicator_cache_tests { use super::*; diff --git a/src/ui/chronology_nav.rs b/src/ui/chronology_nav.rs index ee5497b..3e2441c 100644 --- a/src/ui/chronology_nav.rs +++ b/src/ui/chronology_nav.rs @@ -91,6 +91,13 @@ pub fn nav( } } +/// Clamp a scroll offset so a `body`-row viewport never scrolls past the end of +/// `len` lines. Returns 0 when everything fits. +pub fn clamp_scroll(scroll: usize, len: usize, body: usize) -> usize { + let max = len.saturating_sub(body); + scroll.min(max) +} + /// Adjust the viewport `scroll` so the selected entry index stays visible, /// given how many entries were visible last frame. One-frame lag is fine. pub fn adjust_scroll(scroll: usize, sel_index: usize, visible: usize, len: usize) -> usize { @@ -106,6 +113,18 @@ pub fn adjust_scroll(scroll: usize, sel_index: usize, visible: usize, len: usize scroll } +#[cfg(test)] +mod clamp_scroll_tests { + use super::*; + + #[test] + fn clamp_scroll_bounds() { + assert_eq!(clamp_scroll(85, 100, 20), 80); + assert_eq!(clamp_scroll(5, 100, 20), 5); + assert_eq!(clamp_scroll(7, 10, 20), 0); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/modal.rs b/src/ui/modal.rs index 903de60..4b76453 100644 --- a/src/ui/modal.rs +++ b/src/ui/modal.rs @@ -80,6 +80,15 @@ pub enum Modal { /// (applied) window is read separately from the store at render time. selected: usize, }, + /// Full diff of a chronology change, scrollable. + ChangeDetail { + title: String, + lines: Vec, + scroll: usize, + worktree: std::path::PathBuf, + file: std::path::PathBuf, + line: u32, + }, } fn centered(area: Rect, w: u16, h: u16) -> Rect { @@ -113,6 +122,7 @@ pub fn render(f: &mut Frame, area: Rect, modal: &Modal, tick: u32, theme: &Theme | Modal::RepoSettings { .. } | Modal::AgentsPanel { .. } | Modal::UsageWindowPicker { .. } + | Modal::ChangeDetail { .. } ) { return; } @@ -163,6 +173,7 @@ pub fn render(f: &mut Frame, area: Rect, modal: &Modal, tick: u32, theme: &Theme Modal::UsageWindowPicker { .. } => { unreachable!("UsageWindowPicker must not reach render()") } + Modal::ChangeDetail { .. } => unreachable!("ChangeDetail must not reach render()"), Modal::AgentMissing { agent, binary, .. } => ( "agent not installed", format!( From 0fc9892cacd3b69e2062219a42e913dd78588237 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 18:40:24 -0400 Subject: [PATCH 59/69] feat(chronology): list-only bar that opens the full-change detail modal --- src/app.rs | 37 +++--- src/app/input.rs | 91 ++++++--------- src/app/render.rs | 11 +- src/ui/attached.rs | 66 ++--------- src/ui/chronology_bar.rs | 245 +++++---------------------------------- src/ui/chronology_nav.rs | 183 +++++------------------------ 6 files changed, 114 insertions(+), 519 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4f0917b..42f358b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -167,11 +167,9 @@ pub struct App { /// Scroll offset (entries from the top) of the chronology bar in the /// focused attached pane. pub chronology_scroll: usize, - /// Index of the currently expanded chronology entry, if any. - pub chronology_expanded: Option, - /// Sentinel for the workspace the chronology scroll/expanded state belongs + /// Sentinel for the workspace the chronology scroll/selection state belongs /// to. When the focused attached pane switches to a different workspace, the - /// scroll offset and expanded index are reset so they can't point at an + /// scroll offset and selection index are reset so they can't point at an /// unrelated entry. Mirrors `detail_scroll_last_workspace`. pub chronology_last_workspace: Option, /// Transient per-draw hit-test rects for chronology entries in the focused @@ -183,11 +181,8 @@ pub struct App { pub chronology_bar_rect: Option, /// Keyboard focus is in the chronology bar (intercept nav keys). pub chronology_focused: bool, - /// In-pane cursor while focused. - pub chronology_sel: crate::ui::chronology_nav::ChronoSel, - /// Transient per-frame detail rect of the expanded entry `(index, rect)`, - /// for mouse "open at line". `None` when nothing is expanded/shown. - pub chronology_detail_rect: Option<(usize, ratatui::layout::Rect)>, + /// In-pane cursor (entry index) while focused. + pub chronology_sel: usize, /// Entries drawn in the bar last frame (for keyboard auto-scroll). pub chronology_visible_entries: usize, /// Per-workspace tracking for attention-alert state. @@ -341,13 +336,11 @@ impl App { workspace_events: std::collections::HashMap::new(), chronology: std::collections::HashMap::new(), chronology_scroll: 0, - chronology_expanded: None, chronology_last_workspace: None, chronology_entry_rects: Vec::new(), chronology_bar_rect: None, chronology_focused: false, - chronology_sel: crate::ui::chronology_nav::ChronoSel::default(), - chronology_detail_rect: None, + chronology_sel: 0, chronology_visible_entries: 0, workspace_activity: std::collections::HashMap::new(), workspace_events_scanned: std::collections::HashSet::new(), @@ -653,24 +646,22 @@ pub(crate) fn reset_detail_scroll_on_workspace_change( } } -/// Reset the chronology bar's scroll offset and expanded entry when the focused -/// attached pane switches to a different workspace, so neither can point at an -/// entry that belongs to an unrelated workspace. Mirrors -/// `reset_detail_scroll_on_workspace_change`; takes the fields by `&mut` so the -/// borrow checker can split disjoint borrows of `App` at the call site. +/// Reset the chronology bar's scroll offset, selection, and focus when the +/// focused attached pane switches to a different workspace, so neither scroll +/// nor selection can point at an entry that belongs to an unrelated workspace. +/// Mirrors `reset_detail_scroll_on_workspace_change`; takes the fields by `&mut` +/// so the borrow checker can split disjoint borrows of `App` at the call site. pub(crate) fn reset_chronology_state_on_workspace_change( scroll: &mut usize, - expanded: &mut Option, - focused: &mut bool, - sel: &mut crate::ui::chronology_nav::ChronoSel, + chronology_sel: &mut usize, + chronology_focused: &mut bool, last_workspace: &mut Option, current: Option, ) { if *last_workspace != current { *scroll = 0; - *expanded = None; - *focused = false; - *sel = crate::ui::chronology_nav::ChronoSel::Entry(0); + *chronology_sel = 0; + *chronology_focused = false; *last_workspace = current; } } diff --git a/src/app/input.rs b/src/app/input.rs index 182d7b8..9a16d52 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -288,44 +288,36 @@ fn focused_attached_workspace( Some((ws_id, worktree)) } -/// Open the chronology entry at `idx` in the user's configured editor at the -/// changed line. Requires `editor_cmd` (no `$EDITOR` fallback for this path): -/// surfaces a `Modal::Error` when it's unset or when the spawn fails. -fn open_focused_change(app: &mut App, idx: usize) { - use crate::commands::external::{EditorOpenDecision, editor_open_decision}; - // Clone the path + detail out of the chronology borrow before touching - // app.store / app.modal. - let Some((worktree, file, detail)) = - focused_attached_workspace(app).and_then(|(ws_id, worktree)| { - app.chronology.get(&ws_id).and_then(|t| { - t.events() - .get(idx) - .map(|ev| (worktree, ev.file_path.clone(), ev.detail.clone())) - }) - }) +/// Open the chronology entry at `idx` in the full-change detail modal. +fn open_change_modal(app: &mut App, idx: usize) { + let Some((ws_id, worktree)) = focused_attached_workspace(app) else { + return; + }; + let Some(ev) = app + .chronology + .get(&ws_id) + .and_then(|t| t.events().get(idx).cloned()) else { return; }; - let editor_cmd = app.store.get_setting("editor_cmd").ok().flatten(); - match editor_open_decision(editor_cmd.as_deref()) { - EditorOpenDecision::NeedsConfig => { - app.modal = Some(crate::ui::modal::Modal::Error { - message: "No editor_cmd configured. Set one to open changes in your \ - editor, e.g.\n wsx config set editor_cmd 'alacritty -e nvim'" - .to_string(), - }); - } - EditorOpenDecision::Launch(cmd) => { - let line = crate::activity::chronology::resolve_line_in_file(&file, &detail); - if let Err(e) = - crate::commands::external::open_in_editor_at(&worktree, &file, line, Some(&cmd)) - { - app.modal = Some(crate::ui::modal::Modal::Error { - message: format!("Failed to open editor: {e}"), - }); - } - } - } + let detail = + crate::activity::chronology::load_full_change(&ev).unwrap_or_else(|| ev.detail.clone()); + let line = crate::activity::chronology::resolve_line_in_file(&ev.file_path, &detail); + let lines = crate::ui::chronology_bar::change_detail_lines(&detail, line); + let rel = crate::ui::chronology_bar::relative_display(&ev.file_path, &worktree); + let title = format!( + "{} {}", + crate::ui::chronology_bar::hhmm(ev.timestamp_ms), + rel + ); + app.modal = Some(crate::ui::modal::Modal::ChangeDetail { + title, + lines, + scroll: 0, + worktree, + file: ev.file_path.clone(), + line, + }); } /// Open `file` at `line` using the configured editor, surfacing a Modal::Error @@ -920,7 +912,7 @@ async fn handle_key_attached( }; if !moved { app.chronology_focused = true; - app.chronology_sel = crate::ui::chronology_nav::ChronoSel::Entry(0); + app.chronology_sel = 0; app.chronology_scroll = 0; } return Ok(()); @@ -1083,14 +1075,12 @@ async fn handle_key_attached( .and_then(|(id, _)| app.chronology.get(&id)) .map(|t| t.events().len()) .unwrap_or(0); - let (new_sel, action) = nav(app.chronology_sel, navkey, app.chronology_expanded, len); + let (new_sel, action) = nav(app.chronology_sel, navkey, len); app.chronology_sel = new_sel; match action { NavAction::None => {} - NavAction::Expand(i) => app.chronology_expanded = Some(i), - NavAction::Collapse(_) => app.chronology_expanded = None, NavAction::Exit => app.chronology_focused = false, - NavAction::Open(i) => open_focused_change(app, i), + NavAction::Open(i) => open_change_modal(app, i), } } // While focused, swallow ALL keys — the agent PTY must not receive them. @@ -2059,29 +2049,18 @@ async fn handle_mouse(app: &mut App, m: MouseEvent) { return; } - // Chronology detail click → open the file at the line (mirrors the - // keyboard `Enter`-on-detail). Checked first because the detail block - // lies below the entry's header row. - if let Some((idx, _)) = app.chronology_detail_rect.filter(|(_, r)| { - m.column >= r.x - && m.column < r.x.saturating_add(r.width) - && m.row >= r.y - && m.row < r.y.saturating_add(r.height) - }) { - open_focused_change(app, idx); - app.chronology_focused = true; - app.chronology_sel = crate::ui::chronology_nav::ChronoSel::Detail(idx); - } else if let Some(idx) = app.chronology_entry_rects.iter().find_map(|(i, r)| { + // Chronology entry click → focus the bar, select it, and open the + // full-change detail modal. + if let Some(idx) = app.chronology_entry_rects.iter().find_map(|(i, r)| { let hit = m.column >= r.x && m.column < r.x.saturating_add(r.width) && m.row >= r.y && m.row < r.y.saturating_add(r.height); hit.then_some(*i) }) { - // Header click → focus the bar, select + expand this entry. app.chronology_focused = true; - app.chronology_sel = crate::ui::chronology_nav::ChronoSel::Entry(idx); - app.chronology_expanded = Some(idx); + app.chronology_sel = idx; + open_change_modal(app, idx); } else if let Some(idx) = app.chip_rects.iter().position(|r| { m.column >= r.x && m.column < r.x.saturating_add(r.width) diff --git a/src/app/render.rs b/src/app/render.rs index 9e531ce..efdc08e 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -34,7 +34,6 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { app.agent_chip_rects.clear(); app.chronology_entry_rects.clear(); app.chronology_bar_rect = None; - app.chronology_detail_rect = None; app.usage_graph_rect = None; app.usage_window_option_rects.clear(); @@ -544,12 +543,11 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { .map(|t| t.events().to_vec()) .unwrap_or_default(); // If the focused pane switched to a different workspace, drop any - // stale scroll offset / expanded highlight before reading them. + // stale scroll offset / selection before reading them. crate::app::reset_chronology_state_on_workspace_change( &mut app.chronology_scroll, - &mut app.chronology_expanded, - &mut app.chronology_focused, &mut app.chronology_sel, + &mut app.chronology_focused, &mut app.chronology_last_workspace, Some(focused_id), ); @@ -557,13 +555,12 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { if app.chronology_focused { app.chronology_scroll = crate::ui::chronology_nav::adjust_scroll( app.chronology_scroll, - app.chronology_sel.index(), + app.chronology_sel, app.chronology_visible_entries, chronology_events.len(), ); } let chronology_scroll = app.chronology_scroll; - let chronology_expanded = app.chronology_expanded; // Build the draw data (borrows only the locals above) and carve the // bar's side column out of `pane_area` BEFORE laying out the panes, @@ -576,7 +573,6 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { events: &chronology_events, worktree, scroll: chronology_scroll, - expanded: chronology_expanded, focused: app.chronology_focused, sel: app.chronology_sel, }), @@ -669,7 +665,6 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { app.attached_pane_rects = out.pane_rects; app.agent_chip_rects = out.agent_chip_rects; app.chronology_entry_rects = out.chronology_entry_rects; - app.chronology_detail_rect = out.chronology_detail_rect; app.chronology_visible_entries = out.chronology_visible_entries; app.chronology_bar_rect = bar_rect; app.pinned_commands_cache = pinned; diff --git a/src/ui/attached.rs b/src/ui/attached.rs index 34c6b19..27870d5 100644 --- a/src/ui/attached.rs +++ b/src/ui/attached.rs @@ -32,19 +32,16 @@ pub struct ChronologyDraw<'a> { pub events: &'a [crate::activity::chronology::ChangeEvent], pub worktree: &'a std::path::Path, pub scroll: usize, - pub expanded: Option, /// Keyboard focus is in the bar (drives the active header + selection highlight). pub focused: bool, /// In-pane cursor while focused. - pub sel: crate::ui::chronology_nav::ChronoSel, + pub sel: usize, } /// Mouse/scroll hit targets produced by painting the chronology bar. pub struct ChronologyHits { /// `(entry_index, header_rect)` per drawn entry. pub entries: Vec<(usize, Rect)>, - /// The expanded entry's detail block `(entry_index, rect)`, if any was drawn. - pub detail: Option<(usize, Rect)>, /// Number of entries drawn this frame (for auto-scroll). pub visible_entries: usize, } @@ -62,8 +59,6 @@ pub struct PanesDrawOutput { /// `(entry_index, clickable_rect)` for each rendered chronology entry in the /// focused pane's bar. Empty when the bar isn't shown. pub chronology_entry_rects: Vec<(usize, Rect)>, - /// The expanded entry's detail rect (for mouse "open at line"), if shown. - pub chronology_detail_rect: Option<(usize, Rect)>, /// Entries drawn in the chronology bar this frame (for keyboard auto-scroll). pub chronology_visible_entries: usize, } @@ -160,7 +155,6 @@ pub fn render_panes( Some((bar_rect, draw)) => render_chronology_bar(f, bar_rect, &draw, theme), None => ChronologyHits { entries: Vec::new(), - detail: None, visible_entries: 0, }, }; @@ -199,7 +193,6 @@ pub fn render_panes( pane_rects, agent_chip_rects, chronology_entry_rects: chronology_hits.entries, - chronology_detail_rect: chronology_hits.detail, chronology_visible_entries: chronology_hits.visible_entries, } } @@ -207,8 +200,8 @@ pub fn render_panes( /// Paint the change-chronology bar into `bar_rect` (absolute screen coords): /// a 1-col divider on the bar's inner edge, a `CHANGE CHRONOLOGY` header with a /// side indicator, then the timeline entries from `draw.scroll` down. Returns -/// [`ChronologyHits`] with per-entry header rects, the expanded detail rect, and -/// visible entry count — all for click hit-testing and keyboard auto-scroll. +/// [`ChronologyHits`] with per-entry header rects and visible entry count — +/// both for click hit-testing and keyboard auto-scroll. /// Entries that don't fit vertically are dropped from the end. fn render_chronology_bar( f: &mut Frame, @@ -219,7 +212,6 @@ fn render_chronology_bar( if bar_rect.width == 0 || bar_rect.height == 0 { return ChronologyHits { entries: Vec::new(), - detail: None, visible_entries: 0, }; } @@ -259,7 +251,6 @@ fn render_chronology_bar( if content.width == 0 || content.height == 0 { return ChronologyHits { entries: Vec::new(), - detail: None, visible_entries: 0, }; } @@ -291,7 +282,6 @@ fn render_chronology_bar( if body_y >= body_bottom { return ChronologyHits { entries: Vec::new(), - detail: None, visible_entries: 0, }; } @@ -312,46 +302,20 @@ fn render_chronology_bar( ); return ChronologyHits { entries: Vec::new(), - detail: None, visible_entries: 0, }; } - use crate::ui::chronology_bar::EntryHighlight; - use crate::ui::chronology_nav::ChronoSel; let mut entry_rects: Vec<(usize, Rect)> = Vec::new(); - let mut detail_rect: Option<(usize, Rect)> = None; let mut visible_entries = 0usize; let mut cursor_y = body_y; for (i, ev) in draw.events.iter().enumerate().skip(draw.scroll) { if cursor_y >= body_bottom { break; } - let expanded = Some(i) == draw.expanded; - let highlight = if draw.focused { - match draw.sel { - ChronoSel::Entry(s) if s == i => EntryHighlight::Header, - ChronoSel::Detail(s) if s == i => EntryHighlight::Detail, - _ => EntryHighlight::None, - } - } else { - EntryHighlight::None - }; - // Number the detail peek from the change's resolved line. Only the - // expanded entry shows a peek, so only it needs the (file-reading) lookup. - let base_line = if expanded { - crate::activity::chronology::resolve_line_in_file(&ev.file_path, &ev.detail) - } else { - 1 - }; - let lines = crate::ui::chronology_bar::entry_lines( - ev, - draw.worktree, - expanded, - inner_width, - base_line, - highlight, - ); + let selected = draw.focused && i == draw.sel; + let lines = + crate::ui::chronology_bar::entry_lines(ev, draw.worktree, inner_width, selected); let available = body_bottom.saturating_sub(cursor_y); let drawn = (lines.len() as u16).min(available); if drawn == 0 { @@ -374,24 +338,12 @@ fn render_chronology_bar( height: 1, }, )); - if expanded && drawn > 1 { - detail_rect = Some(( - i, - Rect { - x: content.x, - y: cursor_y.saturating_add(1), - width: inner_width, - height: drawn - 1, - }, - )); - } visible_entries += 1; cursor_y = cursor_y.saturating_add(drawn); } ChronologyHits { entries: entry_rects, - detail: detail_rect, visible_entries, } } @@ -850,9 +802,8 @@ mod tests { events: &events, worktree: std::path::Path::new("/wt"), scroll: 0, - expanded: None, focused: false, - sel: Default::default(), + sel: 0, }; let area = Rect { x: 0, @@ -875,9 +826,8 @@ mod tests { events: &events, worktree: std::path::Path::new("/wt"), scroll: 0, - expanded: None, focused: false, - sel: Default::default(), + sel: 0, }; let area = Rect { x: 0, diff --git a/src/ui/chronology_bar.rs b/src/ui/chronology_bar.rs index 526b627..9e86268 100644 --- a/src/ui/chronology_bar.rs +++ b/src/ui/chronology_bar.rs @@ -73,7 +73,7 @@ fn abbreviate_path(rel: &str, max: usize) -> String { ellipsize_start(rel, max) } -fn hhmm(timestamp_ms: i64) -> String { +pub fn hhmm(timestamp_ms: i64) -> String { // Wall-clock HH:MM (UTC) derived from epoch ms without pulling in chrono — // a relative glance, not a precise local timestamp. Matches the // chrono-free style of activity/events.rs. @@ -83,96 +83,31 @@ fn hhmm(timestamp_ms: i64) -> String { format!("{h:02}:{m:02}") } -/// Which part of an entry is keyboard-selected (for highlight). `None` when the -/// entry isn't the cursor target. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EntryHighlight { - None, - Header, - Detail, -} - -/// Render one entry into lines. Line 1: `HH:MM `, where the path is -/// abbreviated (ancestor dirs collapsed to their first letter) to fit the -/// width. When `expanded`, appends up to a few diff-peek lines from `detail`, -/// each prefixed with a 4-wide line-number gutter: added (`+`) lines are -/// numbered from `base_line`, removed (`-`) lines have a 5-space blank gutter -/// so columns align. +/// One bar row: `HH:MM `, reversed when `selected`. pub fn entry_lines( ev: &ChangeEvent, worktree: &Path, - expanded: bool, width: u16, - base_line: u32, - highlight: EntryHighlight, + selected: bool, ) -> Vec> { - let mut out = Vec::new(); let rel = relative_display(&ev.file_path, worktree); - // The header is `HH:MM ` (6 cols) followed by the path; budget the path to - // the remaining width so long paths abbreviate instead of overflowing. let path_budget = (width as usize).saturating_sub(6); let path = abbreviate_path(&rel, path_budget); - out.push(Line::from(vec![ - Span::styled( - hhmm(ev.timestamp_ms), - Style::default().add_modifier(Modifier::DIM), - ), - Span::raw(" "), - Span::raw(path), - ])); - if expanded { - // (line number, marker, text). `+` (added) lines carry a current-file - // line number starting at base_line; `-` (removed) lines have none. - let mut peek: Vec<(Option, char, String)> = Vec::new(); - match &ev.detail { - ChangeDetail::Edit { old, new } => { - for l in old.lines().take(2) { - peek.push((None, '-', l.to_string())); - } - for (k, l) in new.lines().take(2).enumerate() { - peek.push((Some(base_line.saturating_add(k as u32)), '+', l.to_string())); - } - } - ChangeDetail::Write { head } => { - for (k, l) in head.lines().take(3).enumerate() { - peek.push((Some(base_line.saturating_add(k as u32)), '+', l.to_string())); - } - } - ChangeDetail::None => {} - } - for (gutter, marker, text) in peek { - // 4-col right-aligned number + a space, then marker + text. Removed - // lines use a 5-space blank gutter so the columns line up. - let line = match gutter { - Some(n) => format!("{n:>4} {marker} {text}"), - None => format!(" {marker} {text}"), - }; - let clipped: String = line.chars().take(width as usize).collect(); - out.push(Line::from(Span::styled( - clipped, - Style::default().add_modifier(Modifier::DIM), - ))); - } - } - match highlight { - EntryHighlight::None => {} - EntryHighlight::Header => { - if let Some(first) = out.first_mut() { - for s in &mut first.spans { - s.style = s.style.add_modifier(Modifier::REVERSED); - } - } - } - EntryHighlight::Detail => { - // peek lines are everything after the header (index 0) - for line in out.iter_mut().skip(1) { - for s in &mut line.spans { - s.style = s.style.add_modifier(Modifier::REVERSED); - } - } - } - } - out + let style = if selected { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let time_style = if selected { + Style::default().add_modifier(Modifier::REVERSED | Modifier::DIM) + } else { + Style::default().add_modifier(Modifier::DIM) + }; + vec![Line::from(vec![ + Span::styled(hhmm(ev.timestamp_ms), time_style), + Span::styled(" ", style), + Span::styled(path, style), + ])] } /// Full change as gutter-formatted display strings (no line cap — the modal @@ -240,35 +175,30 @@ mod tests { } #[test] - fn collapsed_entry_is_a_single_header_line() { + fn entry_is_a_single_header_line() { let lines = entry_lines( &ev("/wt/src/main.rs", "fn foo()"), Path::new("/wt"), - false, 40, - 1, - EntryHighlight::None, - ); - assert_eq!( - lines.len(), - 1, - "collapsed: just the time+path header (no summary)" + false, ); + assert_eq!(lines.len(), 1, "one row: the time+path header"); } #[test] - fn expanded_entry_adds_diff_peek_lines() { + fn selected_entry_reverses_its_spans() { let lines = entry_lines( &ev("/wt/src/main.rs", "fn foo()"), Path::new("/wt"), - true, 40, - 1, - EntryHighlight::None, + true, ); assert!( - lines.len() > 1, - "expanded entry is the header plus diff-peek lines" + lines[0] + .spans + .iter() + .all(|s| s.style.add_modifier.contains(Modifier::REVERSED)), + "selected row should be fully reversed" ); } @@ -301,105 +231,6 @@ mod tests { assert!(out.ends_with(".rs")); } - #[test] - fn header_highlight_reverses_first_line() { - let lines = entry_lines( - &ev("/wt/a.rs", "fn foo()"), - Path::new("/wt"), - true, - 40, - 1, - EntryHighlight::Header, - ); - let has_rev = lines[0] - .spans - .iter() - .any(|s| s.style.add_modifier.contains(Modifier::REVERSED)); - assert!(has_rev, "header line should be highlighted"); - } - - #[test] - fn detail_highlight_reverses_peek_lines_only() { - let lines = entry_lines( - &ev("/wt/a.rs", "fn foo()"), - Path::new("/wt"), - true, - 40, - 1, - EntryHighlight::Detail, - ); - assert!( - !lines[0] - .spans - .iter() - .any(|s| s.style.add_modifier.contains(Modifier::REVERSED)), - "header must NOT be highlighted in Detail mode" - ); - let peek_rev = lines.iter().skip(1).any(|l| { - l.spans - .iter() - .any(|s| s.style.add_modifier.contains(Modifier::REVERSED)) - }); - assert!(peek_rev, "detail peek should be highlighted"); - } - - #[test] - fn no_highlight_leaves_lines_unreversed() { - let lines = entry_lines( - &ev("/wt/a.rs", "fn foo()"), - Path::new("/wt"), - false, - 40, - 1, - EntryHighlight::None, - ); - assert!( - !lines.iter().any(|l| l - .spans - .iter() - .any(|s| s.style.add_modifier.contains(Modifier::REVERSED))), - "no highlight expected" - ); - } - - fn line_text(line: &Line<'_>) -> String { - line.spans.iter().map(|s| s.content.as_ref()).collect() - } - - #[test] - fn peek_numbers_added_lines_and_blanks_removed_gutter() { - let ev = ChangeEvent { - timestamp_ms: 0, - tool: ChangeTool::Edit, - file_path: PathBuf::from("/wt/a.rs"), - summary: String::new(), - detail: ChangeDetail::Edit { - old: "old0\nold1".into(), - new: "new0\nnew1".into(), - }, - source: ChangeSource::default(), - }; - let lines = entry_lines(&ev, Path::new("/wt"), true, 60, 42, EntryHighlight::None); - let texts: Vec = lines.iter().map(line_text).collect(); - // out[0] header; out[1..3] removed (blank gutter); out[3..5] added (42, 43) - assert!( - texts[1].starts_with(" -"), - "removed gutter blank: {:?}", - texts[1] - ); - assert!(texts[2].starts_with(" -"), "{:?}", texts[2]); - assert!( - texts[3].contains("42") && texts[3].contains("+ new0"), - "{:?}", - texts[3] - ); - assert!( - texts[4].contains("43") && texts[4].contains("+ new1"), - "{:?}", - texts[4] - ); - } - #[test] fn change_detail_lines_edit_full_no_cap() { let detail = ChangeDetail::Edit { @@ -426,24 +257,4 @@ mod tests { fn change_detail_lines_none_is_empty() { assert!(change_detail_lines(&ChangeDetail::None, 1).is_empty()); } - - #[test] - fn write_peek_numbers_from_base_line() { - let ev = ChangeEvent { - timestamp_ms: 0, - tool: ChangeTool::Write, - file_path: PathBuf::from("/wt/a.rs"), - summary: String::new(), - detail: ChangeDetail::Write { - head: "l1\nl2\nl3".into(), - }, - source: ChangeSource::default(), - }; - let lines = entry_lines(&ev, Path::new("/wt"), true, 60, 1, EntryHighlight::None); - let texts: Vec = lines.iter().map(line_text).collect(); - // Assert the full gutter so a missing number can't pass via "l1" etc. - assert_eq!(texts[1], " 1 + l1", "{:?}", texts[1]); - assert_eq!(texts[2], " 2 + l2", "{:?}", texts[2]); - assert_eq!(texts[3], " 3 + l3", "{:?}", texts[3]); - } } diff --git a/src/ui/chronology_nav.rs b/src/ui/chronology_nav.rs index 3e2441c..ebd2067 100644 --- a/src/ui/chronology_nav.rs +++ b/src/ui/chronology_nav.rs @@ -3,28 +3,6 @@ //! //! See `docs/superpowers/specs/2026-06-05-chronology-keyboard-navigation-design.md`. -/// In-pane cursor while the chronology bar is keyboard-focused. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChronoSel { - Entry(usize), - Detail(usize), -} - -impl Default for ChronoSel { - fn default() -> Self { - ChronoSel::Entry(0) - } -} - -impl ChronoSel { - /// The entry index this cursor refers to (entry or its detail). - pub fn index(self) -> usize { - match self { - ChronoSel::Entry(i) | ChronoSel::Detail(i) => i, - } - } -} - /// A navigation key, already mapped from the raw keystroke. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NavKey { @@ -40,21 +18,13 @@ pub enum NavKey { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NavAction { None, - Expand(usize), - Collapse(usize), Open(usize), Exit, } -/// Pure transition. `expanded` is the currently-expanded entry index (the bar -/// allows one at a time); `len` is the entry count. Bounds-safe: never returns -/// an index >= `len` (when `len > 0`). -pub fn nav( - sel: ChronoSel, - key: NavKey, - expanded: Option, - len: usize, -) -> (ChronoSel, NavAction) { +/// Pure transition for the single-level entry list. `len` is the entry count. +/// Bounds-safe: never returns an index >= `len` (when `len > 0`). +pub fn nav(sel: usize, key: NavKey, len: usize) -> (usize, NavAction) { if key == NavKey::Esc { return (sel, NavAction::Exit); } @@ -62,32 +32,13 @@ pub fn nav( return (sel, NavAction::None); } let last = len - 1; - match (sel, key) { - (ChronoSel::Entry(i), NavKey::Down) => { - if expanded == Some(i) { - (ChronoSel::Detail(i), NavAction::None) - } else { - (ChronoSel::Entry((i + 1).min(last)), NavAction::None) - } - } - (ChronoSel::Detail(i), NavKey::Down) => { - (ChronoSel::Entry((i + 1).min(last)), NavAction::None) - } - (ChronoSel::Detail(i), NavKey::Up) => (ChronoSel::Entry(i), NavAction::None), - (ChronoSel::Entry(i), NavKey::Up) => { - (ChronoSel::Entry(i.saturating_sub(1)), NavAction::None) - } - (_, NavKey::Top) => (ChronoSel::Entry(0), NavAction::None), - (_, NavKey::Bottom) => (ChronoSel::Entry(last), NavAction::None), - (ChronoSel::Entry(i), NavKey::Enter) => { - if expanded == Some(i) { - (ChronoSel::Entry(i), NavAction::Collapse(i)) - } else { - (ChronoSel::Entry(i), NavAction::Expand(i)) - } - } - (ChronoSel::Detail(i), NavKey::Enter) => (ChronoSel::Detail(i), NavAction::Open(i)), - (_, NavKey::Esc) => unreachable!(), + match key { + NavKey::Down => ((sel + 1).min(last), NavAction::None), + NavKey::Up => (sel.saturating_sub(1), NavAction::None), + NavKey::Top => (0, NavAction::None), + NavKey::Bottom => (last, NavAction::None), + NavKey::Enter => (sel, NavAction::Open(sel)), + NavKey::Esc => unreachable!(), } } @@ -130,115 +81,39 @@ mod tests { use super::*; #[test] - fn down_moves_to_next_entry_when_collapsed() { - assert_eq!( - nav(ChronoSel::Entry(0), NavKey::Down, None, 3), - (ChronoSel::Entry(1), NavAction::None) - ); + fn down_moves_to_next_entry_clamping_at_last() { + assert_eq!(nav(0, NavKey::Down, 3), (1, NavAction::None)); + assert_eq!(nav(2, NavKey::Down, 3), (2, NavAction::None)); } #[test] - fn down_steps_into_detail_when_expanded() { - assert_eq!( - nav(ChronoSel::Entry(1), NavKey::Down, Some(1), 3), - (ChronoSel::Detail(1), NavAction::None) - ); - } - - #[test] - fn down_from_detail_goes_to_next_entry() { - assert_eq!( - nav(ChronoSel::Detail(1), NavKey::Down, Some(1), 3), - (ChronoSel::Entry(2), NavAction::None) - ); - } - - #[test] - fn up_from_detail_returns_to_entry() { - assert_eq!( - nav(ChronoSel::Detail(2), NavKey::Up, Some(2), 3), - (ChronoSel::Entry(2), NavAction::None) - ); - } - - #[test] - fn up_from_entry_goes_previous_saturating() { - assert_eq!( - nav(ChronoSel::Entry(1), NavKey::Up, None, 3), - (ChronoSel::Entry(0), NavAction::None) - ); - assert_eq!( - nav(ChronoSel::Entry(0), NavKey::Up, None, 3), - (ChronoSel::Entry(0), NavAction::None) - ); - } - - #[test] - fn down_clamps_at_last() { - assert_eq!( - nav(ChronoSel::Entry(2), NavKey::Down, None, 3), - (ChronoSel::Entry(2), NavAction::None) - ); + fn up_goes_previous_saturating() { + assert_eq!(nav(1, NavKey::Up, 3), (0, NavAction::None)); + assert_eq!(nav(0, NavKey::Up, 3), (0, NavAction::None)); } #[test] fn top_and_bottom() { - assert_eq!( - nav(ChronoSel::Detail(1), NavKey::Top, Some(1), 3), - (ChronoSel::Entry(0), NavAction::None) - ); - assert_eq!( - nav(ChronoSel::Entry(0), NavKey::Bottom, None, 3), - (ChronoSel::Entry(2), NavAction::None) - ); + assert_eq!(nav(1, NavKey::Top, 3), (0, NavAction::None)); + assert_eq!(nav(0, NavKey::Bottom, 3), (2, NavAction::None)); } #[test] - fn enter_toggles_expand_on_entry() { - assert_eq!( - nav(ChronoSel::Entry(1), NavKey::Enter, None, 3), - (ChronoSel::Entry(1), NavAction::Expand(1)) - ); - assert_eq!( - nav(ChronoSel::Entry(1), NavKey::Enter, Some(1), 3), - (ChronoSel::Entry(1), NavAction::Collapse(1)) - ); - } - - #[test] - fn enter_on_detail_opens() { - assert_eq!( - nav(ChronoSel::Detail(1), NavKey::Enter, Some(1), 3), - (ChronoSel::Detail(1), NavAction::Open(1)) - ); + fn enter_opens_current_selection() { + assert_eq!(nav(1, NavKey::Enter, 3), (1, NavAction::Open(1))); } #[test] fn esc_exits_from_anywhere() { - assert_eq!( - nav(ChronoSel::Entry(0), NavKey::Esc, None, 3).1, - NavAction::Exit - ); - assert_eq!( - nav(ChronoSel::Detail(2), NavKey::Esc, Some(2), 3).1, - NavAction::Exit - ); + assert_eq!(nav(0, NavKey::Esc, 3).1, NavAction::Exit); + assert_eq!(nav(2, NavKey::Esc, 3).1, NavAction::Exit); } #[test] - fn empty_list_only_exits() { - assert_eq!( - nav(ChronoSel::Entry(0), NavKey::Down, None, 0), - (ChronoSel::Entry(0), NavAction::None) - ); - assert_eq!( - nav(ChronoSel::Entry(0), NavKey::Enter, None, 0), - (ChronoSel::Entry(0), NavAction::None) - ); - assert_eq!( - nav(ChronoSel::Entry(0), NavKey::Esc, None, 0).1, - NavAction::Exit - ); + fn empty_list_is_a_no_op_except_esc() { + assert_eq!(nav(0, NavKey::Down, 0), (0, NavAction::None)); + assert_eq!(nav(0, NavKey::Enter, 0), (0, NavAction::None)); + assert_eq!(nav(0, NavKey::Esc, 0).1, NavAction::Exit); } #[test] @@ -248,10 +123,4 @@ mod tests { assert_eq!(adjust_scroll(2, 3, 4, 10), 2); assert_eq!(adjust_scroll(3, 0, 4, 0), 0); } - - #[test] - fn index_extracts_entry_index() { - assert_eq!(ChronoSel::Entry(4).index(), 4); - assert_eq!(ChronoSel::Detail(7).index(), 7); - } } From f8c43991a719b4c69f272fff5f4dea2e54edcd87 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 18:44:58 -0400 Subject: [PATCH 60/69] docs: chronology detail modal (replaces inline peek docs) --- README.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ef6c099..de3910d 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,7 @@ Keystrokes are forwarded to the running `claude` session, except: | `↓` / `j` *(bar focused)* | Move selection down (toward older entries) | | `g` *(bar focused)* | Jump to the top (newest entry) | | `G` *(bar focused)* | Jump to the bottom (oldest entry) | -| `Enter` *(bar focused)* | Toggle expand/collapse the selected entry's diff peek (press again to collapse; or, when inside a diff peek, open the file in your editor at the changed line) | -| `↓` *(entry expanded)* | Move focus into the diff peek detail | -| `↑` *(inside diff peek)* | Return focus to the entry | +| `Enter` *(bar focused)* | Open the full-change detail modal for the selected entry | | `Esc` *(bar focused)* | Return focus to the agent pane | When a workspace has more than one agent, the footer also binds bare keys `q w r y i o p s h j` (no leader) to switch the focused pane between agents — see [Multi-agent workspaces](#multi-agent-workspaces). @@ -611,7 +609,7 @@ The rename only fires on workspaces whose name still matches the generated ` Date: Sat, 6 Jun 2026 18:48:44 -0400 Subject: [PATCH 61/69] =?UTF-8?q?test(chronology):=20end-to-end=20parse=5F?= =?UTF-8?q?file=20=E2=86=92=20load=5Ffull=5Fchange=20returns=20untruncated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/activity/chronology.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index 9bdf7ae..98cc40f 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -760,4 +760,42 @@ mod source_tests { }; assert!(load_full_change(&ev).is_none()); } + + #[test] + fn parse_file_then_load_full_change_returns_untruncated() { + // End-to-end: the in-memory event is clipped, but its source lets the + // modal re-extract the whole change. Proves parse_file populates source + // and index_in_line lines up with the full re-extraction. + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("s.jsonl"); + let big = "x".repeat(DETAIL_MAX_CHARS + 50); + let mut f = std::fs::File::create(&path).unwrap(); + writeln!( + f, + r#"{{"type":"assistant","timestamp":"2026-05-14T17:00:00.000Z","message":{{"content":[{{"type":"tool_use","name":"Write","input":{{"file_path":"/wt/a.rs","content":"{big}"}}}}]}}}}"# + ) + .unwrap(); + let evs = parse_file(&path); + assert_eq!(evs.len(), 1); + match &evs[0].detail { + ChangeDetail::Write { head } => { + assert_eq!( + head.chars().count(), + DETAIL_MAX_CHARS, + "in-memory detail is clipped" + ); + } + _ => panic!("expected Write"), + } + match load_full_change(&evs[0]).expect("full re-extract") { + ChangeDetail::Write { head } => { + assert_eq!( + head.chars().count(), + DETAIL_MAX_CHARS + 50, + "full content recovered" + ); + } + _ => panic!("expected Write"), + } + } } From 42cfc5723273f33216c02f96e7b505d36368fa61 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 21:15:14 -0400 Subject: [PATCH 62/69] docs: design spec for chronology modal syntax highlighting --- ...6-06-chronology-syntax-highlight-design.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-06-chronology-syntax-highlight-design.md diff --git a/docs/superpowers/specs/2026-06-06-chronology-syntax-highlight-design.md b/docs/superpowers/specs/2026-06-06-chronology-syntax-highlight-design.md new file mode 100644 index 0000000..a2cf251 --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-chronology-syntax-highlight-design.md @@ -0,0 +1,179 @@ +# Chronology Modal Syntax Highlighting — Design + +**Date:** 2026-06-06 +**Status:** Approved for planning +**Builds on:** the chronology detail modal (`Modal::ChangeDetail`, `change_detail_lines`). + +## Problem + +The detail modal renders the change as plain, uncolored text. Code is harder to +scan without syntax coloring, and the added/removed sides aren't visually +distinct beyond the `+`/`-` glyph. + +## Goal + +Add **basic, dependency-free** syntax highlighting to the code shown in the +modal, plus a diff tint, without pulling in a heavy highlighter. + +- Code tokens (keywords, strings, line comments, numbers) are colored, by + language detected from the file extension. +- Diff tint is carried by the marker: green `+` / red `-`; the line-number + gutter stays dim. (Syntax-first: every line's code is highlighted; whole-line + green/red tinting is intentionally NOT used, because the peek is all `+`/`-` + lines and full-line tint would hide the syntax colors.) +- No new crate dependencies (wsx keeps a lean dependency set). + +## Decisions (from brainstorming) + +- **A — lightweight hand-rolled highlighter** (not `syntect`). Zero new deps. +- **Syntax-first + colored marker** (not whole-line diff tint). +- Languages: **Rust**, generic **C-like**, **Python**, **Shell**; unknown → + plain (no highlight). +- Per-line highlighting (no cross-line block-comment/multi-line-string state) — + acceptable "basic" fidelity for a glanceable peek. + +## Architecture + +A new pure module `src/ui/syntax.rs` owns highlighting and the styled +change-line builder. The modal stores pre-highlighted styled `Line`s (computed +once on open); the renderer slices vertically and clips horizontally. + +``` +open modal: detail + file path + → lang_for_path(file) -> Option + → change_detail_lines_styled(detail, base_line, lang) -> Vec> + per line: dim gutter span · green/red marker span · highlight_code(code, lang) spans + → Modal::ChangeDetail { lines: Vec>, … } +render: clip_line_to_width(line, inner_width) per visible line +``` + +## Components (`src/ui/syntax.rs`, new — pure) + +### Language spec + detection +```rust +/// A minimal language description driving the generic tokenizer. +pub struct LangSpec { + pub keywords: &'static [&'static str], + pub line_comment: &'static [&'static str], // e.g. ["//"], ["#"] + pub string_delims: &'static [char], // e.g. ['"', '\''] +} + +/// Pick a `LangSpec` from a path's extension; `None` → no highlighting. +pub fn lang_for_path(path: &Path) -> Option<&'static LangSpec>; +``` +Extension mapping: +- `rs` → RUST +- `c|h|cc|cpp|cxx|hpp|hh|js|jsx|ts|tsx|go|java|cs|json` → CLIKE +- `py` → PYTHON +- `sh|bash|zsh` → SHELL +- otherwise → `None`. + +Static `LangSpec`s (representative keyword sets; not exhaustive): +- **RUST** keywords: `fn let mut pub use struct enum impl trait for in if else match while loop return self Self mod const static move ref as where async await dyn crate super type unsafe break continue true false`; line_comment `["//"]`; strings `['"']`. +- **CLIKE** keywords: `if else for while switch case break continue return struct class const static void int char bool new delete public private protected function var let import export from default true false null`; line_comment `["//"]`; strings `['"', '\'']`. +- **PYTHON** keywords: `def class return if elif else for while import from as with try except finally raise lambda None True False and or not in is pass yield global nonlocal`; line_comment `["#"]`; strings `['"', '\'']`. +- **SHELL** keywords: `if then else elif fi for in do done while case esac function return export local`; line_comment `["#"]`; strings `['"', '\'']`. + +### Token highlighter +```rust +/// Tokenize ONE line of code into styled spans by `spec`. Recognizes (in +/// priority order): line comments (rest of line), strings (delim..delim with +/// `\` escape), numbers (leading-digit runs), keywords (whole identifiers), +/// else default. Pure; no cross-line state. +pub fn highlight_code(text: &str, spec: &LangSpec) -> Vec>; +``` +Single left-to-right scan over chars: +- At a `line_comment` prefix → emit the rest of the line as a comment span; stop. +- At a `string_delim` → consume to the matching delim (honoring `\` escapes) → + string span. +- At an ASCII digit starting a token → consume the numeric run → number span. +- At an identifier start (`alpha`/`_`) → consume the identifier; if it's in + `keywords` → keyword span, else default span. +- Else accumulate into a default span. +Adjacent default chars coalesce into one span. + +### Palette +`ratatui::style::Color`: keyword = `Magenta`, string = `Yellow`, +comment = `DarkGray`, number = `Cyan`, default = unset fg. Marker `+` = `Green`, +`-` = `Red`. Gutter = `Modifier::DIM`. (Fixed palette; no theme plumbing.) + +### Styled change-line builder +```rust +/// Build the modal's styled diff lines: each line is a dim 4-col gutter, a +/// green `+` / red `-` marker, then `highlight_code(code, lang)` (or a plain +/// span when `lang` is None). Added (`+`) lines numbered from `base_line`, +/// removed (`-`) lines blank gutter. No line cap. +pub fn change_detail_lines_styled( + detail: &ChangeDetail, + base_line: u32, + lang: Option<&LangSpec>, +) -> Vec>; +``` +Replaces `chronology_bar::change_detail_lines` (only the modal used it; remove it ++ its tests). The gutter/marker format is unchanged (`"{n:>4} "` / 5-space +blank, then the marker), just split into styled spans instead of one string. + +### Horizontal clip +```rust +/// Truncate a styled `Line` to `width` display columns, preserving span styles +/// (drops/trims spans past the limit). Char-based width. +pub fn clip_line_to_width(line: &Line<'static>, width: usize) -> Line<'static>; +``` + +## Integration + +- `src/ui/mod.rs`: `pub mod syntax;`. +- `src/ui/modal.rs`: `Modal::ChangeDetail.lines: Vec` → `Vec>`. +- `src/app/input.rs` `open_change_modal`: compute + `let lang = crate::ui::syntax::lang_for_path(&ev.file_path);` and + `let lines = crate::ui::syntax::change_detail_lines_styled(&detail, line, lang);` + (replacing the `change_detail_lines` call). Everything else (title, scroll, + worktree/file/line, `e`) unchanged. +- `src/app/render.rs` `render_change_detail_modal`: `lines: &[Line<'static>]`; + each visible line rendered as `clip_line_to_width(line, inner.width as usize)` + instead of `Span::raw(string.take(width))`. Footer/scroll/clamp unchanged. +- `src/ui/chronology_bar.rs`: remove `change_detail_lines` and its tests. + +Note: the modal's stored `lines` are now styled `Line`s; the existing +clone-on-keystroke in the modal input handler clones them — acceptable at human +keystroke rate for one bounded change (documented; not optimized here). + +## Error handling / edge cases + +- Unknown extension / no extension → `lang_for_path` returns `None` → + `change_detail_lines_styled` emits plain (default-styled) code spans. No error. +- Empty detail (`ChangeDetail::None`) → no lines (as today). +- Per-line scan only: a string/comment spanning multiple lines highlights each + line independently (a trailing `"` or `/*` won't carry to the next line) — + accepted "basic" limitation. +- `clip_line_to_width(_, 0)` → empty line; width ≥ content → unchanged. +- Non-ASCII: scanning is char-based; identifier/number checks use ASCII + predicates, so non-ASCII chars fall into default spans (safe, no panic). + +## Testing (pure) + +- **`lang_for_path`**: `a.rs`→RUST, `a.py`→PYTHON, `a.c`/`a.js`→CLIKE, + `a.sh`→SHELL, `a.txt`/no-ext→None. +- **`highlight_code`** (RUST): `let x = "hi"; // c` → a `let` keyword span, a + `"hi"` string span, a `// c` comment span (rest of line), and `42` → + number span; assert the styled segments carry the right `Color`. +- **string escape**: `"a\"b"` stays one string span. +- **`change_detail_lines_styled`**: `+` line marker is Green and numbered from + `base_line`; `-` line marker is Red with blank gutter; gutter span is DIM; + with `lang=None` the code is a single default span; with RUST, a keyword in + the code is Magenta. +- **`clip_line_to_width`**: clipping mid-span keeps the earlier spans' styles and + trims the boundary span; width 0 → empty; wide width → identical spans. +- Modal render/input glue: build + manual (open a Rust change → colored tokens, + green/red markers; scroll; narrow terminal clips without losing the gutter). + +## Files touched + +- `src/ui/syntax.rs` (new) — `LangSpec`, `lang_for_path`, `highlight_code`, + `change_detail_lines_styled`, `clip_line_to_width`, palette + tests. +- `src/ui/mod.rs` — `pub mod syntax;`. +- `src/ui/modal.rs` — `ChangeDetail.lines` type → `Vec>`. +- `src/app/input.rs` — `open_change_modal` uses the styled builder + lang. +- `src/app/render.rs` — render styled lines via `clip_line_to_width`. +- `src/ui/chronology_bar.rs` — remove `change_detail_lines` (+ tests). +- `README.md` — note basic syntax highlighting + diff-tinted markers in the modal. From 91df7a92d508936e05ef6f8b6ac4e37f9fd962e2 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 21:27:28 -0400 Subject: [PATCH 63/69] docs: implementation plan for chronology modal syntax highlighting --- .../2026-06-06-chronology-syntax-highlight.md | 496 ++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-06-chronology-syntax-highlight.md diff --git a/docs/superpowers/plans/2026-06-06-chronology-syntax-highlight.md b/docs/superpowers/plans/2026-06-06-chronology-syntax-highlight.md new file mode 100644 index 0000000..f968821 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-chronology-syntax-highlight.md @@ -0,0 +1,496 @@ +# Chronology Modal Syntax Highlighting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Basic, dependency-free syntax highlighting + diff-tinted markers for the code shown in the chronology detail modal. + +**Architecture:** A new pure `src/ui/syntax.rs` — a generic per-language tokenizer (`LangSpec` + `highlight_code`), `lang_for_path`, a styled diff-line builder (`change_detail_lines_styled`), and `clip_line_to_width`. The modal stores pre-highlighted `Vec>` (built on open); the renderer clips styled spans to width. + +**Tech Stack:** Rust, `ratatui` (`Span`/`Line`/`Style`/`Color`). No new deps. `#[cfg(test)]` unit tests. + +**Builds on (verified current code):** +- `src/ui/modal.rs`: `Modal::ChangeDetail { title: String, lines: Vec, scroll: usize, worktree: PathBuf, file: PathBuf, line: u32 }` (+ an early-return guard and an `unreachable!` arm both matching `Modal::ChangeDetail { .. }`). +- `src/app/render.rs` `render_change_detail_modal(f, area, title, lines: &[String], scroll, theme)`: visible lines built as `Line::from(Span::raw(l.chars().take(inner.width).collect()))`; uses `clamp_scroll`; footer with `n-m/total`. +- `src/app/input.rs` `open_change_modal(app, idx)`: builds `detail` (via `load_full_change` fallback), `line` (`resolve_line_in_file`), `lines = crate::ui::chronology_bar::change_detail_lines(&detail, line)`, `title`, and sets `Modal::ChangeDetail{…}`. Modal input handler clones the modal and reads `lines.len()`. +- `src/ui/chronology_bar.rs`: `pub fn change_detail_lines(detail, base_line) -> Vec` (added/removed gutter format `"{n:>4} + {l}"` / `" - {l}"`) + 3 tests (`change_detail_lines_*`). Only `open_change_modal` calls it. + +--- + +## File Structure + +- `src/ui/syntax.rs` (new) — `LangSpec`, lang statics, `lang_for_path`, `highlight_code`, `change_detail_lines_styled`, `clip_line_to_width`, palette, tests. +- `src/ui/mod.rs` — `pub mod syntax;`. +- `src/ui/modal.rs` — `ChangeDetail.lines` type → `Vec>`. +- `src/app/render.rs` — render styled lines via `clip_line_to_width`. +- `src/app/input.rs` — `open_change_modal` uses `syntax::change_detail_lines_styled` + `lang_for_path`. +- `src/ui/chronology_bar.rs` — remove `change_detail_lines` (+ its 3 tests). +- `README.md`. + +--- + +## Task 1: `syntax.rs` — `LangSpec`, `lang_for_path`, `highlight_code` + +**Files:** Create `src/ui/syntax.rs`; modify `src/ui/mod.rs`. + +- [ ] **Step 1: Create the module with tests-first** + +Create `src/ui/syntax.rs`: + +```rust +//! Basic, dependency-free syntax highlighting for the chronology detail modal. +//! A single generic tokenizer driven by a per-language `LangSpec`. Per-line, +//! no cross-line state — "basic" fidelity for a glanceable diff. + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use std::path::Path; + +/// Minimal language description driving the generic tokenizer. +pub struct LangSpec { + pub keywords: &'static [&'static str], + pub line_comment: &'static [&'static str], + pub string_delims: &'static [char], +} + +static RUST: LangSpec = LangSpec { + keywords: &[ + "fn", "let", "mut", "pub", "use", "struct", "enum", "impl", "trait", "for", "in", "if", + "else", "match", "while", "loop", "return", "self", "Self", "mod", "const", "static", + "move", "ref", "as", "where", "async", "await", "dyn", "crate", "super", "type", "unsafe", + "break", "continue", "true", "false", + ], + line_comment: &["//"], + string_delims: &['"'], +}; + +static CLIKE: LangSpec = LangSpec { + keywords: &[ + "if", "else", "for", "while", "switch", "case", "break", "continue", "return", "struct", + "class", "const", "static", "void", "int", "char", "bool", "new", "delete", "public", + "private", "protected", "function", "var", "let", "import", "export", "from", "default", + "true", "false", "null", + ], + line_comment: &["//"], + string_delims: &['"', '\''], +}; + +static PYTHON: LangSpec = LangSpec { + keywords: &[ + "def", "class", "return", "if", "elif", "else", "for", "while", "import", "from", "as", + "with", "try", "except", "finally", "raise", "lambda", "None", "True", "False", "and", + "or", "not", "in", "is", "pass", "yield", "global", "nonlocal", + ], + line_comment: &["#"], + string_delims: &['"', '\''], +}; + +static SHELL: LangSpec = LangSpec { + keywords: &[ + "if", "then", "else", "elif", "fi", "for", "in", "do", "done", "while", "case", "esac", + "function", "return", "export", "local", + ], + line_comment: &["#"], + string_delims: &['"', '\''], +}; + +/// Pick a `LangSpec` from a path's extension; `None` → no highlighting. +pub fn lang_for_path(path: &Path) -> Option<&'static LangSpec> { + let ext = path.extension().and_then(|e| e.to_str())?; + match ext { + "rs" => Some(&RUST), + "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "js" | "jsx" | "ts" | "tsx" | "go" + | "java" | "cs" | "json" => Some(&CLIKE), + "py" => Some(&PYTHON), + "sh" | "bash" | "zsh" => Some(&SHELL), + _ => None, + } +} + +fn kw_style() -> Style { Style::default().fg(Color::Magenta) } +fn str_style() -> Style { Style::default().fg(Color::Yellow) } +fn comment_style() -> Style { Style::default().fg(Color::DarkGray) } +fn num_style() -> Style { Style::default().fg(Color::Cyan) } + +fn flush(spans: &mut Vec>, default: &mut String) { + if !default.is_empty() { + spans.push(Span::raw(std::mem::take(default))); + } +} + +fn take_while(rest: &str, pred: impl Fn(char) -> bool) -> (String, usize) { + let mut tok = String::new(); + let mut bytes = 0; + for c in rest.chars() { + if pred(c) { + tok.push(c); + bytes += c.len_utf8(); + } else { + break; + } + } + (tok, bytes) +} + +fn take_string(rest: &str, delim: char) -> (String, usize) { + let mut tok = String::new(); + let mut bytes = 0; + let mut chars = rest.chars(); + let open = chars.next().unwrap(); + tok.push(open); + bytes += open.len_utf8(); + let mut escaped = false; + for c in chars { + tok.push(c); + bytes += c.len_utf8(); + if escaped { + escaped = false; + continue; + } + if c == '\\' { + escaped = true; + continue; + } + if c == delim { + break; + } + } + (tok, bytes) +} + +/// Tokenize ONE line of code into styled spans by `spec`. Priority: line +/// comment (rest of line) > string > number > keyword/identifier > default. +pub fn highlight_code(text: &str, spec: &LangSpec) -> Vec> { + let mut spans: Vec> = Vec::new(); + let mut default = String::new(); + let mut i = 0; + while i < text.len() { + let rest = &text[i..]; + if let Some(cm) = spec.line_comment.iter().find(|c| rest.starts_with(**c)) { + let _ = cm; + flush(&mut spans, &mut default); + spans.push(Span::styled(rest.to_string(), comment_style())); + return spans; + } + let ch = rest.chars().next().unwrap(); + if spec.string_delims.contains(&ch) { + flush(&mut spans, &mut default); + let (tok, consumed) = take_string(rest, ch); + spans.push(Span::styled(tok, str_style())); + i += consumed; + } else if ch.is_ascii_digit() { + flush(&mut spans, &mut default); + let (tok, consumed) = take_while(rest, |c| c.is_ascii_digit() || c == '.' || c == '_'); + spans.push(Span::styled(tok, num_style())); + i += consumed; + } else if ch.is_alphabetic() || ch == '_' { + let (tok, consumed) = take_while(rest, |c| c.is_alphanumeric() || c == '_'); + if spec.keywords.contains(&tok.as_str()) { + flush(&mut spans, &mut default); + spans.push(Span::styled(tok, kw_style())); + } else { + default.push_str(&tok); + } + i += consumed; + } else { + default.push(ch); + i += ch.len_utf8(); + } + } + flush(&mut spans, &mut default); + spans +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lang_for_path_maps_extensions() { + assert!(lang_for_path(Path::new("a.rs")).is_some()); + assert!(lang_for_path(Path::new("a.py")).is_some()); + assert!(lang_for_path(Path::new("a.c")).is_some()); + assert!(lang_for_path(Path::new("a.js")).is_some()); + assert!(lang_for_path(Path::new("a.sh")).is_some()); + assert!(lang_for_path(Path::new("a.txt")).is_none()); + assert!(lang_for_path(Path::new("noext")).is_none()); + } + + fn texts(spans: &[Span<'static>]) -> Vec<(String, Option)> { + spans.iter().map(|s| (s.content.to_string(), s.style.fg)).collect() + } + + #[test] + fn highlight_rust_keyword_string_comment_number() { + let spans = highlight_code(r#"let x = "hi"; // c"#, &RUST); + let t = texts(&spans); + assert!(t.iter().any(|(s, c)| s == "let" && *c == Some(Color::Magenta)), "{t:?}"); + assert!(t.iter().any(|(s, c)| s == "\"hi\"" && *c == Some(Color::Yellow)), "{t:?}"); + assert!(t.iter().any(|(s, c)| s == "// c" && *c == Some(Color::DarkGray)), "{t:?}"); + + let nums = highlight_code("x = 42", &RUST); + assert!(texts(&nums).iter().any(|(s, c)| s == "42" && *c == Some(Color::Cyan))); + } + + #[test] + fn highlight_string_keeps_escaped_quote_in_one_span() { + let spans = highlight_code(r#""a\"b""#, &RUST); + let t = texts(&spans); + assert_eq!(t.len(), 1); + assert_eq!(t[0].0, r#""a\"b""#); + assert_eq!(t[0].1, Some(Color::Yellow)); + } + + #[test] + fn non_keyword_identifier_is_default() { + let spans = highlight_code("foobar", &RUST); + let t = texts(&spans); + assert_eq!(t, vec![("foobar".to_string(), None)]); + } +} +``` + +In `src/ui/mod.rs` add `pub mod syntax;`. + +- [ ] **Step 2: Run to verify (red→green)** + +Run: `cargo test --lib syntax` → the 4 tests pass once the module compiles. (No separate red phase needed; if any fail, the tokenizer is wrong — fix it.) + +- [ ] **Step 3: Build** + +Run: `cargo build` — clean. (Unused warnings for `change_detail_lines_styled`/`clip_line_to_width` don't exist yet; they're added next task. `highlight_code`/`lang_for_path` are `pub`, consumed next task — no dead-code warning.) + +- [ ] **Step 4: Commit** + +```bash +git add src/ui/syntax.rs src/ui/mod.rs +git commit -m "feat(chronology): basic per-language syntax tokenizer (syntax.rs)" +``` + +--- + +## Task 2: styled diff-line builder + `clip_line_to_width` + +**Files:** Modify `src/ui/syntax.rs`. + +- [ ] **Step 1: Failing tests** + +Add to `src/ui/syntax.rs` tests (it needs `ChangeDetail`): + +```rust + use crate::activity::chronology::ChangeDetail; + + fn line_text(line: &Line<'static>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn styled_lines_marker_colors_and_gutter() { + let detail = ChangeDetail::Edit { old: "old".into(), new: "let y = 1".into() }; + let lines = change_detail_lines_styled(&detail, 7, lang_for_path(Path::new("a.rs"))); + // removed line: dim gutter (5 spaces), red "- " marker + assert_eq!(lines[0].spans[0].content.as_ref(), " "); + assert!(lines[0].spans[0].style.add_modifier.contains(Modifier::DIM)); + assert_eq!(lines[0].spans[1].content.as_ref(), "- "); + assert_eq!(lines[0].spans[1].style.fg, Some(Color::Red)); + // added line: gutter " 7 ", green "+ ", code highlighted (let = magenta) + assert_eq!(lines[1].spans[0].content.as_ref(), " 7 "); + assert_eq!(lines[1].spans[1].content.as_ref(), "+ "); + assert_eq!(lines[1].spans[1].style.fg, Some(Color::Green)); + assert!(lines[1].spans.iter().any(|s| s.content.as_ref() == "let" && s.style.fg == Some(Color::Magenta))); + } + + #[test] + fn styled_lines_plain_when_no_lang() { + let detail = ChangeDetail::Write { head: "let y = 1".into() }; + let lines = change_detail_lines_styled(&detail, 1, None); + // code is a single default span (no highlighting) + assert_eq!(lines[0].spans[2].content.as_ref(), "let y = 1"); + assert_eq!(lines[0].spans[2].style.fg, None); + } + + #[test] + fn clip_line_preserves_styles_and_truncates() { + let detail = ChangeDetail::Write { head: "abcdefgh".into() }; + let line = &change_detail_lines_styled(&detail, 1, None)[0]; // " 1 + abcdefgh" + let clipped = clip_line_to_width(line, 7); + assert_eq!(line_text(&clipped), " 1 + "); + assert_eq!(clip_line_to_width(line, 0).spans.len(), 0); + let wide = clip_line_to_width(line, 999); + assert_eq!(line_text(&wide), " 1 + abcdefgh"); + } +``` + +- [ ] **Step 2: Verify failure** + +Run: `cargo test --lib syntax` → FAIL (`change_detail_lines_styled` / `clip_line_to_width` undefined). + +- [ ] **Step 3: Implement** + +Add to `src/ui/syntax.rs` (non-test), with `use crate::activity::chronology::ChangeDetail;` at the top: + +```rust +fn code_spans(code: &str, lang: Option<&LangSpec>) -> Vec> { + match lang { + Some(spec) => highlight_code(code, spec), + None => vec![Span::raw(code.to_string())], + } +} + +/// Build the modal's styled diff lines: dim 4-col gutter, green `+` / red `-` +/// marker, then highlighted code (or a plain span when `lang` is None). Added +/// lines numbered from `base_line`; removed lines blank gutter. No line cap. +pub fn change_detail_lines_styled( + detail: &ChangeDetail, + base_line: u32, + lang: Option<&LangSpec>, +) -> Vec> { + let dim = Style::default().add_modifier(Modifier::DIM); + let add = Style::default().fg(Color::Green); + let del = Style::default().fg(Color::Red); + let mut out = Vec::new(); + let mut push = |gutter: String, marker_style: Style, marker: &str, code: &str, out: &mut Vec>| { + let mut spans = vec![ + Span::styled(gutter, dim), + Span::styled(marker.to_string(), marker_style), + ]; + spans.extend(code_spans(code, lang)); + out.push(Line::from(spans)); + }; + match detail { + ChangeDetail::Edit { old, new } => { + for l in old.lines() { + push(" ".to_string(), del, "- ", l, &mut out); + } + for (k, l) in new.lines().enumerate() { + let n = base_line.saturating_add(k as u32); + push(format!("{n:>4} "), add, "+ ", l, &mut out); + } + } + ChangeDetail::Write { head } => { + for (k, l) in head.lines().enumerate() { + let n = base_line.saturating_add(k as u32); + push(format!("{n:>4} "), add, "+ ", l, &mut out); + } + } + ChangeDetail::None => {} + } + out +} + +/// Truncate a styled `Line` to `width` display columns (char-based), preserving +/// span styles; the boundary span is trimmed. +pub fn clip_line_to_width(line: &Line<'static>, width: usize) -> Line<'static> { + let mut out: Vec> = Vec::new(); + let mut used = 0; + for span in &line.spans { + if used >= width { + break; + } + let remaining = width - used; + let cnt = span.content.chars().count(); + if cnt <= remaining { + out.push(span.clone()); + used += cnt; + } else { + let truncated: String = span.content.chars().take(remaining).collect(); + out.push(Span::styled(truncated, span.style)); + break; + } + } + Line::from(out) +} +``` + +(The `push` closure captures `dim`/`add`/`del`/`lang` — all `Copy` — and takes `out` as a param to avoid a double mutable borrow.) + +- [ ] **Step 4: Verify pass** + +Run: `cargo test --lib syntax` (all pass), `cargo build` (zero warnings), `cargo fmt` + `cargo fmt --check`. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/syntax.rs +git commit -m "feat(chronology): styled diff-line builder + clip_line_to_width" +``` + +--- + +## Task 3: Wire the modal to styled, highlighted lines + +**Files:** Modify `src/ui/modal.rs`, `src/app/input.rs`, `src/app/render.rs`, `src/ui/chronology_bar.rs`. + +- [ ] **Step 1: Modal variant type** + +In `src/ui/modal.rs`, change `Modal::ChangeDetail`'s field `lines: Vec` → `lines: Vec>`. (The early-return guard and `unreachable!` arm match `ChangeDetail { .. }`, so they're unaffected.) + +- [ ] **Step 2: `open_change_modal` builds styled lines** + +In `src/app/input.rs` `open_change_modal`, replace: +```rust + let lines = crate::ui::chronology_bar::change_detail_lines(&detail, line); +``` +with: +```rust + let lang = crate::ui::syntax::lang_for_path(&ev.file_path); + let lines = crate::ui::syntax::change_detail_lines_styled(&detail, line, lang); +``` +Everything else in the function (title, scroll, worktree/file/line) is unchanged. The modal input handler that reconstructs `Modal::ChangeDetail { …, lines, … }` and reads `lines.len()` works unchanged with `Vec`. + +- [ ] **Step 3: Render styled lines** + +In `src/app/render.rs` `render_change_detail_modal`: +- Change the signature param `lines: &[String]` → `lines: &[ratatui::text::Line<'static>]`. +- Replace the `visible` builder: +```rust + let visible: Vec = lines + .iter() + .skip(scroll) + .take(body_h) + .map(|l| crate::ui::syntax::clip_line_to_width(l, inner.width as usize)) + .collect(); +``` +The dispatch call site `render_change_detail_modal(f, area, title, lines, *scroll, &app.theme)` is unchanged (now passes `&Vec`). Footer/clamp/scroll unchanged. + +- [ ] **Step 4: Remove the superseded `change_detail_lines`** + +In `src/ui/chronology_bar.rs`, delete `pub fn change_detail_lines` and its three tests (`change_detail_lines_edit_full_no_cap`, `change_detail_lines_write_numbers_all`, `change_detail_lines_none_is_empty`). Remove any now-unused imports that drops (e.g. if nothing else uses an import). + +- [ ] **Step 5: Verify** + +Run: `cargo build` (ZERO warnings — fix any unused import left by the removal), `cargo test --lib` (all pass), `cargo clippy --lib` (no new lints), `cargo fmt` + `cargo fmt --check`. +Manual: attach, open a Rust change in the modal → keywords magenta, strings yellow, comments gray, numbers cyan; `+` markers green, `-` red, gutter dim; scroll; narrow the terminal → lines clip without losing the gutter; open a `.txt`/no-ext change → plain (no colors), no crash. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat(chronology): syntax-highlight + diff-tint the detail modal" +``` + +--- + +## Task 4: README + +**Files:** Modify `README.md`. + +- [ ] **Step 1: Document it** + +In the "Change chronology" section's detail-modal description, add that the modal shows **basic syntax highlighting** (keywords/strings/comments/numbers, by file type — Rust, C-like, Python, Shell; other types shown plain) with **green `+` / red `-` diff markers**. Match the existing prose style; keep it to a sentence or two. + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: note syntax highlighting in the chronology detail modal" +``` + +--- + +## Self-Review (completed during planning) + +**Spec coverage:** `LangSpec`/`lang_for_path`/`highlight_code` (T1) ✓; palette (T1) ✓; `change_detail_lines_styled` styled builder w/ green/red markers + dim gutter + highlighted/plain code (T2) ✓; `clip_line_to_width` (T2) ✓; modal `lines: Vec` + open builds styled + render clips styled (T3) ✓; remove old `change_detail_lines` (T3) ✓; README (T4) ✓; syntax-first/colored-marker model (markers colored, code highlighted — T2) ✓; unknown ext → plain (T1 `lang_for_path` None + T2 `code_spans` None) ✓. + +**Placeholder scan:** complete code in every code step; the only prose-only step (T4 README) is a doc edit. The `let _ = cm;` in `highlight_code` silences the unused `find` binding without changing behavior (line-comment prefix matched; rest emitted). + +**Type consistency:** `LangSpec`, `lang_for_path(&Path) -> Option<&'static LangSpec>`, `highlight_code(&str, &LangSpec) -> Vec>`, `change_detail_lines_styled(&ChangeDetail, u32, Option<&LangSpec>) -> Vec>`, `clip_line_to_width(&Line<'static>, usize) -> Line<'static>`, `Modal::ChangeDetail.lines: Vec>`, `render_change_detail_modal(.., lines: &[Line<'static>], ..)` — consistent across tasks. From 6794a1545394a5f180efaf945f4a429e3fada2b5 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 21:33:39 -0400 Subject: [PATCH 64/69] feat(chronology): basic per-language syntax tokenizer (syntax.rs) --- src/ui/mod.rs | 1 + src/ui/syntax.rs | 264 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 src/ui/syntax.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 938a1d9..7e400a8 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,6 +5,7 @@ pub mod dashboard; pub mod modal; pub mod pm_pane; pub mod split; +pub mod syntax; pub mod theme; pub mod updates_bar; diff --git a/src/ui/syntax.rs b/src/ui/syntax.rs new file mode 100644 index 0000000..de13e5f --- /dev/null +++ b/src/ui/syntax.rs @@ -0,0 +1,264 @@ +//! Basic, dependency-free syntax highlighting for the chronology detail modal. +//! A single generic tokenizer driven by a per-language `LangSpec`. Per-line, +//! no cross-line state — "basic" fidelity for a glanceable diff. + +use ratatui::style::{Color, Style}; +use ratatui::text::Span; +use std::path::Path; + +/// Minimal language description driving the generic tokenizer. +pub struct LangSpec { + pub keywords: &'static [&'static str], + pub line_comment: &'static [&'static str], + pub string_delims: &'static [char], +} + +static RUST: LangSpec = LangSpec { + keywords: &[ + "fn", "let", "mut", "pub", "use", "struct", "enum", "impl", "trait", "for", "in", "if", + "else", "match", "while", "loop", "return", "self", "Self", "mod", "const", "static", + "move", "ref", "as", "where", "async", "await", "dyn", "crate", "super", "type", "unsafe", + "break", "continue", "true", "false", + ], + line_comment: &["//"], + string_delims: &['"'], +}; + +static CLIKE: LangSpec = LangSpec { + keywords: &[ + "if", + "else", + "for", + "while", + "switch", + "case", + "break", + "continue", + "return", + "struct", + "class", + "const", + "static", + "void", + "int", + "char", + "bool", + "new", + "delete", + "public", + "private", + "protected", + "function", + "var", + "let", + "import", + "export", + "from", + "default", + "true", + "false", + "null", + ], + line_comment: &["//"], + string_delims: &['"', '\''], +}; + +static PYTHON: LangSpec = LangSpec { + keywords: &[ + "def", "class", "return", "if", "elif", "else", "for", "while", "import", "from", "as", + "with", "try", "except", "finally", "raise", "lambda", "None", "True", "False", "and", + "or", "not", "in", "is", "pass", "yield", "global", "nonlocal", + ], + line_comment: &["#"], + string_delims: &['"', '\''], +}; + +static SHELL: LangSpec = LangSpec { + keywords: &[ + "if", "then", "else", "elif", "fi", "for", "in", "do", "done", "while", "case", "esac", + "function", "return", "export", "local", + ], + line_comment: &["#"], + string_delims: &['"', '\''], +}; + +/// Pick a `LangSpec` from a path's extension; `None` → no highlighting. +pub fn lang_for_path(path: &Path) -> Option<&'static LangSpec> { + let ext = path.extension().and_then(|e| e.to_str())?; + match ext { + "rs" => Some(&RUST), + "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" | "hh" | "js" | "jsx" | "ts" | "tsx" | "go" + | "java" | "cs" | "json" => Some(&CLIKE), + "py" => Some(&PYTHON), + "sh" | "bash" | "zsh" => Some(&SHELL), + _ => None, + } +} + +fn kw_style() -> Style { + Style::default().fg(Color::Magenta) +} +fn str_style() -> Style { + Style::default().fg(Color::Yellow) +} +fn comment_style() -> Style { + Style::default().fg(Color::DarkGray) +} +fn num_style() -> Style { + Style::default().fg(Color::Cyan) +} + +fn flush(spans: &mut Vec>, default: &mut String) { + if !default.is_empty() { + spans.push(Span::raw(std::mem::take(default))); + } +} + +fn take_while(rest: &str, pred: impl Fn(char) -> bool) -> (String, usize) { + let mut tok = String::new(); + let mut bytes = 0; + for c in rest.chars() { + if pred(c) { + tok.push(c); + bytes += c.len_utf8(); + } else { + break; + } + } + (tok, bytes) +} + +fn take_string(rest: &str, delim: char) -> (String, usize) { + let mut tok = String::new(); + let mut bytes = 0; + let mut chars = rest.chars(); + let open = chars.next().unwrap(); + tok.push(open); + bytes += open.len_utf8(); + let mut escaped = false; + for c in chars { + tok.push(c); + bytes += c.len_utf8(); + if escaped { + escaped = false; + continue; + } + if c == '\\' { + escaped = true; + continue; + } + if c == delim { + break; + } + } + (tok, bytes) +} + +/// Tokenize ONE line of code into styled spans by `spec`. Priority: line +/// comment (rest of line) > string > number > keyword/identifier > default. +pub fn highlight_code(text: &str, spec: &LangSpec) -> Vec> { + let mut spans: Vec> = Vec::new(); + let mut default = String::new(); + let mut i = 0; + while i < text.len() { + let rest = &text[i..]; + if spec.line_comment.iter().any(|c| rest.starts_with(c)) { + flush(&mut spans, &mut default); + spans.push(Span::styled(rest.to_string(), comment_style())); + return spans; + } + let ch = rest.chars().next().unwrap(); + if spec.string_delims.contains(&ch) { + flush(&mut spans, &mut default); + let (tok, consumed) = take_string(rest, ch); + spans.push(Span::styled(tok, str_style())); + i += consumed; + } else if ch.is_ascii_digit() { + flush(&mut spans, &mut default); + let (tok, consumed) = take_while(rest, |c| c.is_ascii_digit() || c == '.' || c == '_'); + spans.push(Span::styled(tok, num_style())); + i += consumed; + } else if ch.is_alphabetic() || ch == '_' { + let (tok, consumed) = take_while(rest, |c| c.is_alphanumeric() || c == '_'); + if spec.keywords.contains(&tok.as_str()) { + flush(&mut spans, &mut default); + spans.push(Span::styled(tok, kw_style())); + } else { + default.push_str(&tok); + } + i += consumed; + } else { + default.push(ch); + i += ch.len_utf8(); + } + } + flush(&mut spans, &mut default); + spans +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lang_for_path_maps_extensions() { + assert!(lang_for_path(Path::new("a.rs")).is_some()); + assert!(lang_for_path(Path::new("a.py")).is_some()); + assert!(lang_for_path(Path::new("a.c")).is_some()); + assert!(lang_for_path(Path::new("a.js")).is_some()); + assert!(lang_for_path(Path::new("a.sh")).is_some()); + assert!(lang_for_path(Path::new("a.txt")).is_none()); + assert!(lang_for_path(Path::new("noext")).is_none()); + } + + fn texts(spans: &[Span<'static>]) -> Vec<(String, Option)> { + spans + .iter() + .map(|s| (s.content.to_string(), s.style.fg)) + .collect() + } + + #[test] + fn highlight_rust_keyword_string_comment_number() { + let spans = highlight_code(r#"let x = "hi"; // c"#, &RUST); + let t = texts(&spans); + assert!( + t.iter() + .any(|(s, c)| s == "let" && *c == Some(Color::Magenta)), + "{t:?}" + ); + assert!( + t.iter() + .any(|(s, c)| s == "\"hi\"" && *c == Some(Color::Yellow)), + "{t:?}" + ); + assert!( + t.iter() + .any(|(s, c)| s == "// c" && *c == Some(Color::DarkGray)), + "{t:?}" + ); + + let nums = highlight_code("x = 42", &RUST); + assert!( + texts(&nums) + .iter() + .any(|(s, c)| s == "42" && *c == Some(Color::Cyan)) + ); + } + + #[test] + fn highlight_string_keeps_escaped_quote_in_one_span() { + let spans = highlight_code(r#""a\"b""#, &RUST); + let t = texts(&spans); + assert_eq!(t.len(), 1); + assert_eq!(t[0].0, r#""a\"b""#); + assert_eq!(t[0].1, Some(Color::Yellow)); + } + + #[test] + fn non_keyword_identifier_is_default() { + let spans = highlight_code("foobar", &RUST); + let t = texts(&spans); + assert_eq!(t, vec![("foobar".to_string(), None)]); + } +} From c9460a26dc71f3cf0d6f30445639d2045859798f Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 21:35:45 -0400 Subject: [PATCH 65/69] feat(chronology): styled diff-line builder + clip_line_to_width --- src/ui/syntax.rs | 134 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 2 deletions(-) diff --git a/src/ui/syntax.rs b/src/ui/syntax.rs index de13e5f..8f0533a 100644 --- a/src/ui/syntax.rs +++ b/src/ui/syntax.rs @@ -2,8 +2,9 @@ //! A single generic tokenizer driven by a per-language `LangSpec`. Per-line, //! no cross-line state — "basic" fidelity for a glanceable diff. -use ratatui::style::{Color, Style}; -use ratatui::text::Span; +use crate::activity::chronology::ChangeDetail; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; use std::path::Path; /// Minimal language description driving the generic tokenizer. @@ -196,10 +197,139 @@ pub fn highlight_code(text: &str, spec: &LangSpec) -> Vec> { spans } +fn code_spans(code: &str, lang: Option<&LangSpec>) -> Vec> { + match lang { + Some(spec) => highlight_code(code, spec), + None => vec![Span::raw(code.to_string())], + } +} + +/// Build the modal's styled diff lines: dim 4-col gutter, green `+` / red `-` +/// marker, then highlighted code (or a plain span when `lang` is None). Added +/// lines numbered from `base_line`; removed lines blank gutter. No line cap. +pub fn change_detail_lines_styled( + detail: &ChangeDetail, + base_line: u32, + lang: Option<&LangSpec>, +) -> Vec> { + let dim = Style::default().add_modifier(Modifier::DIM); + let add = Style::default().fg(Color::Green); + let del = Style::default().fg(Color::Red); + let mut out = Vec::new(); + let push = |gutter: String, + marker_style: Style, + marker: &str, + code: &str, + out: &mut Vec>| { + let mut spans = vec![ + Span::styled(gutter, dim), + Span::styled(marker.to_string(), marker_style), + ]; + spans.extend(code_spans(code, lang)); + out.push(Line::from(spans)); + }; + match detail { + ChangeDetail::Edit { old, new } => { + for l in old.lines() { + push(" ".to_string(), del, "- ", l, &mut out); + } + for (k, l) in new.lines().enumerate() { + let n = base_line.saturating_add(k as u32); + push(format!("{n:>4} "), add, "+ ", l, &mut out); + } + } + ChangeDetail::Write { head } => { + for (k, l) in head.lines().enumerate() { + let n = base_line.saturating_add(k as u32); + push(format!("{n:>4} "), add, "+ ", l, &mut out); + } + } + ChangeDetail::None => {} + } + out +} + +/// Truncate a styled `Line` to `width` display columns (char-based), preserving +/// span styles; the boundary span is trimmed. +pub fn clip_line_to_width(line: &Line<'static>, width: usize) -> Line<'static> { + let mut out: Vec> = Vec::new(); + let mut used = 0; + for span in &line.spans { + if used >= width { + break; + } + let remaining = width - used; + let cnt = span.content.chars().count(); + if cnt <= remaining { + out.push(span.clone()); + used += cnt; + } else { + let truncated: String = span.content.chars().take(remaining).collect(); + out.push(Span::styled(truncated, span.style)); + break; + } + } + Line::from(out) +} + #[cfg(test)] mod tests { use super::*; + use crate::activity::chronology::ChangeDetail; + + fn line_text(line: &Line<'static>) -> String { + line.spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn styled_lines_marker_colors_and_gutter() { + let detail = ChangeDetail::Edit { + old: "old".into(), + new: "let y = 1".into(), + }; + let lines = change_detail_lines_styled(&detail, 7, lang_for_path(Path::new("a.rs"))); + // removed line: dim gutter (5 spaces), red "- " marker + assert_eq!(lines[0].spans[0].content.as_ref(), " "); + assert!(lines[0].spans[0].style.add_modifier.contains(Modifier::DIM)); + assert_eq!(lines[0].spans[1].content.as_ref(), "- "); + assert_eq!(lines[0].spans[1].style.fg, Some(Color::Red)); + // added line: gutter " 7 ", green "+ ", code highlighted (let = magenta) + assert_eq!(lines[1].spans[0].content.as_ref(), " 7 "); + assert_eq!(lines[1].spans[1].content.as_ref(), "+ "); + assert_eq!(lines[1].spans[1].style.fg, Some(Color::Green)); + assert!( + lines[1] + .spans + .iter() + .any(|s| s.content.as_ref() == "let" && s.style.fg == Some(Color::Magenta)) + ); + } + + #[test] + fn styled_lines_plain_when_no_lang() { + let detail = ChangeDetail::Write { + head: "let y = 1".into(), + }; + let lines = change_detail_lines_styled(&detail, 1, None); + // code is a single default span (no highlighting): spans = [gutter, marker, code] + assert_eq!(lines[0].spans[2].content.as_ref(), "let y = 1"); + assert_eq!(lines[0].spans[2].style.fg, None); + } + + #[test] + fn clip_line_preserves_styles_and_truncates() { + let detail = ChangeDetail::Write { + head: "abcdefgh".into(), + }; + let line = &change_detail_lines_styled(&detail, 1, None)[0]; // " 1 + abcdefgh" + let clipped = clip_line_to_width(line, 7); + assert_eq!(line_text(&clipped), " 1 + "); + assert_eq!(clip_line_to_width(line, 0).spans.len(), 0); + let wide = clip_line_to_width(line, 999); + assert_eq!(line_text(&wide), " 1 + abcdefgh"); + } + #[test] fn lang_for_path_maps_extensions() { assert!(lang_for_path(Path::new("a.rs")).is_some()); From bb4905a3b03e3bb2466030c446c42f742d755f80 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 21:41:21 -0400 Subject: [PATCH 66/69] feat(chronology): syntax-highlight + diff-tint the detail modal --- src/app/input.rs | 3 ++- src/app/render.rs | 8 ++---- src/ui/chronology_bar.rs | 57 ++-------------------------------------- src/ui/modal.rs | 2 +- 4 files changed, 7 insertions(+), 63 deletions(-) diff --git a/src/app/input.rs b/src/app/input.rs index 9a16d52..21293f5 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -303,7 +303,8 @@ fn open_change_modal(app: &mut App, idx: usize) { let detail = crate::activity::chronology::load_full_change(&ev).unwrap_or_else(|| ev.detail.clone()); let line = crate::activity::chronology::resolve_line_in_file(&ev.file_path, &detail); - let lines = crate::ui::chronology_bar::change_detail_lines(&detail, line); + let lang = crate::ui::syntax::lang_for_path(&ev.file_path); + let lines = crate::ui::syntax::change_detail_lines_styled(&detail, line, lang); let rel = crate::ui::chronology_bar::relative_display(&ev.file_path, &worktree); let title = format!( "{} {}", diff --git a/src/app/render.rs b/src/app/render.rs index efdc08e..0e06087 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -1027,7 +1027,7 @@ fn render_change_detail_modal( f: &mut ratatui::Frame, area: ratatui::layout::Rect, title: &str, - lines: &[String], + lines: &[ratatui::text::Line<'static>], scroll: usize, theme: &crate::ui::theme::Theme, ) { @@ -1057,11 +1057,7 @@ fn render_change_detail_modal( .iter() .skip(scroll) .take(body_h) - .map(|l| { - Line::from(Span::raw( - l.chars().take(inner.width as usize).collect::(), - )) - }) + .map(|l| crate::ui::syntax::clip_line_to_width(l, inner.width as usize)) .collect(); let body_area = Rect { height: inner.height.saturating_sub(1), diff --git a/src/ui/chronology_bar.rs b/src/ui/chronology_bar.rs index 9e86268..7e3b22e 100644 --- a/src/ui/chronology_bar.rs +++ b/src/ui/chronology_bar.rs @@ -2,7 +2,7 @@ //! (`src/ui/attached.rs`) carves the side column and calls these to build the //! content lines; keeping the formatting pure makes it unit-testable. -use crate::activity::chronology::{ChangeDetail, ChangeEvent}; +use crate::activity::chronology::ChangeEvent; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use std::path::Path; @@ -110,36 +110,10 @@ pub fn entry_lines( ])] } -/// Full change as gutter-formatted display strings (no line cap — the modal -/// scrolls). Removed (`-`) lines get a 5-space blank gutter; added (`+`) lines -/// are numbered from `base_line` (4-wide right-aligned). -pub fn change_detail_lines(detail: &ChangeDetail, base_line: u32) -> Vec { - let mut out = Vec::new(); - match detail { - ChangeDetail::Edit { old, new } => { - for l in old.lines() { - out.push(format!(" - {l}")); - } - for (k, l) in new.lines().enumerate() { - let n = base_line.saturating_add(k as u32); - out.push(format!("{n:>4} + {l}")); - } - } - ChangeDetail::Write { head } => { - for (k, l) in head.lines().enumerate() { - let n = base_line.saturating_add(k as u32); - out.push(format!("{n:>4} + {l}")); - } - } - ChangeDetail::None => {} - } - out -} - #[cfg(test)] mod tests { use super::*; - use crate::activity::chronology::{ChangeSource, ChangeTool}; + use crate::activity::chronology::{ChangeDetail, ChangeSource, ChangeTool}; use std::path::PathBuf; fn ev(file: &str, summary: &str) -> ChangeEvent { @@ -230,31 +204,4 @@ mod tests { assert!(out.chars().count() <= 12); assert!(out.ends_with(".rs")); } - - #[test] - fn change_detail_lines_edit_full_no_cap() { - let detail = ChangeDetail::Edit { - old: "o1\no2\no3".into(), - new: "n1\nn2\nn3".into(), - }; - let lines = change_detail_lines(&detail, 10); - assert_eq!(lines.len(), 6, "all 3 old + 3 new, no take(2) cap"); - assert!(lines[0].starts_with(" - o1"), "{:?}", lines[0]); - assert_eq!(lines[3], " 10 + n1"); - assert_eq!(lines[5], " 12 + n3"); - } - - #[test] - fn change_detail_lines_write_numbers_all() { - let detail = ChangeDetail::Write { - head: "a\nb".into(), - }; - let lines = change_detail_lines(&detail, 1); - assert_eq!(lines, vec![" 1 + a".to_string(), " 2 + b".to_string()]); - } - - #[test] - fn change_detail_lines_none_is_empty() { - assert!(change_detail_lines(&ChangeDetail::None, 1).is_empty()); - } } diff --git a/src/ui/modal.rs b/src/ui/modal.rs index 4b76453..da7a73d 100644 --- a/src/ui/modal.rs +++ b/src/ui/modal.rs @@ -83,7 +83,7 @@ pub enum Modal { /// Full diff of a chronology change, scrollable. ChangeDetail { title: String, - lines: Vec, + lines: Vec>, scroll: usize, worktree: std::path::PathBuf, file: std::path::PathBuf, From d71c21428232ec476decac8ff447fdfd2f7a1942 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sat, 6 Jun 2026 21:44:24 -0400 Subject: [PATCH 67/69] docs: note syntax highlighting in the chronology detail modal --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index de3910d..1daaebf 100644 --- a/README.md +++ b/README.md @@ -631,6 +631,8 @@ The modal is a scrollable overlay showing the full diff of the selected change: - Press `e` to open the file in your editor at the changed line (requires `editor_cmd` — see below). - Press `Esc` or click outside the modal to close it and return to the bar. +The diff is displayed with basic syntax highlighting for Rust, Python, Shell, and a generic C-like family (C/C++/JS/TS/Go/Java/JSON, and similar); other file types are shown plain. Added (`+`) lines are tinted green and removed (`-`) lines red; the line-number gutter stays dim. Highlighting is per-line — multi-line strings or block comments may not be perfectly colored. + #### Keybindings (attached view, under the `Ctrl-x` leader) | Key | Action | From 6f4636be1e87759efea315e6cd81565478f1c95f Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 7 Jun 2026 08:10:07 -0400 Subject: [PATCH 68/69] fix(chronology): incorporate PR feedback - Throttle the per-frame chronology refresh (read_dir + per-file stat) to ~3x/sec, refreshing immediately on first focus of a workspace so the bar isn't briefly empty. - Clamp the bar wheel-scroll to the last page (len - visible) via clamp_scroll, instead of len-1 which let it scroll into empty space. - Detail-modal footer shows "0-0/0" for an empty change instead of "1-0/0". Declined: closing the modal on any click (vs only outside-click) is kept as-is. --- src/app.rs | 4 ++++ src/app/input.rs | 14 +++++++++----- src/app/render.rs | 19 +++++++++++++++++-- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index 42f358b..0d466d9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -185,6 +185,9 @@ pub struct App { pub chronology_sel: usize, /// Entries drawn in the bar last frame (for keyboard auto-scroll). pub chronology_visible_entries: usize, + /// Epoch-ms of the last chronology timeline refresh (throttles the + /// per-frame session-log re-scan; see `draw`). + pub chronology_last_refresh_ms: i64, /// Per-workspace tracking for attention-alert state. pub workspace_activity: std::collections::HashMap, @@ -342,6 +345,7 @@ impl App { chronology_focused: false, chronology_sel: 0, chronology_visible_entries: 0, + chronology_last_refresh_ms: 0, workspace_activity: std::collections::HashMap::new(), workspace_events_scanned: std::collections::HashSet::new(), workspace_needs_attention: std::collections::HashSet::new(), diff --git a/src/app/input.rs b/src/app/input.rs index 21293f5..556b89e 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -1990,12 +1990,16 @@ async fn handle_mouse(app: &mut App, m: MouseEvent) { .and_then(|(id, _)| app.chronology.get(&id)) .map(|t| t.events().len()) .unwrap_or(0); - let max_scroll = len.saturating_sub(1); - if matches!(m.kind, MouseEventKind::ScrollDown) { - app.chronology_scroll = (app.chronology_scroll + 3).min(max_scroll); + // Clamp to the last *page* (len - visible_rows), not len-1, so the + // wheel can't scroll into empty space past the last entry. + let visible = app.chronology_visible_entries; + let target = if matches!(m.kind, MouseEventKind::ScrollDown) { + app.chronology_scroll.saturating_add(3) } else { - app.chronology_scroll = app.chronology_scroll.saturating_sub(3); - } + app.chronology_scroll.saturating_sub(3) + }; + app.chronology_scroll = + crate::ui::chronology_nav::clamp_scroll(target, len, visible); return; } } diff --git a/src/app/render.rs b/src/app/render.rs index 0e06087..42ce54c 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -55,7 +55,20 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { None }; if let Some((ws_id, worktree)) = focused { - app.refresh_chronology(ws_id, &worktree); + // Throttle the session-log re-scan: `refresh_chronology` does a + // `read_dir` + per-file `stat` (the parse itself is (size,mtime)- + // cached), so running it on every frame is wasteful. Refresh at most + // ~3×/sec, but immediately the first time a workspace is focused so + // its bar isn't briefly empty. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + let stale = now.saturating_sub(app.chronology_last_refresh_ms) >= 300; + if stale || !app.chronology.contains_key(&ws_id) { + app.chronology_last_refresh_ms = now; + app.refresh_chronology(ws_id, &worktree); + } } } @@ -1065,9 +1078,11 @@ fn render_change_detail_modal( }; f.render_widget(Paragraph::new(visible), body_area); let end = (scroll + body_h).min(lines.len()); + // Show 0-based "0-0/0" for an empty change rather than a confusing "1-0/0". + let start = if lines.is_empty() { 0 } else { scroll + 1 }; let footer = format!( "↑/↓ j/k PgUp/PgDn g/G · e editor · Esc close {}-{}/{}", - scroll + 1, + start, end, lines.len() ); From 92953c6ffcd09da84a01a7efd8c7e74decfbcb60 Mon Sep 17 00:00:00 2001 From: Eben Goodman Date: Sun, 7 Jun 2026 08:12:59 -0400 Subject: [PATCH 69/69] fix(chronology): use slice::from_ref in timeline tests (clippy --all-targets) --- src/activity/chronology.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs index 98cc40f..e7292dc 100644 --- a/src/activity/chronology.rs +++ b/src/activity/chronology.rs @@ -656,9 +656,9 @@ mod timeline_tests { let a = dir.path().join("a.jsonl"); write_event(&a, "2026-05-14T17:00:00.000Z", "/wt/old.rs"); let mut tl = Timeline::default(); - tl.refresh(&[a.clone()]); + tl.refresh(std::slice::from_ref(&a)); assert_eq!(tl.parse_count(), 1); - tl.refresh(&[a.clone()]); // same size+mtime → cache hit + tl.refresh(std::slice::from_ref(&a)); // same size+mtime → cache hit assert_eq!(tl.parse_count(), 1, "should not reparse unchanged file"); } @@ -668,9 +668,9 @@ mod timeline_tests { let a = dir.path().join("a.jsonl"); write_event(&a, "2026-05-14T17:00:00.000Z", "/wt/old.rs"); let mut tl = Timeline::default(); - tl.refresh(&[a.clone()]); + tl.refresh(std::slice::from_ref(&a)); write_event(&a, "2026-05-14T19:00:00.000Z", "/wt/newer.rs"); - tl.refresh(&[a.clone()]); + tl.refresh(std::slice::from_ref(&a)); assert_eq!(tl.parse_count(), 2, "size changed → reparse"); assert_eq!(tl.events().len(), 2); } @@ -686,7 +686,7 @@ mod timeline_tests { tl.refresh(&[a.clone(), b.clone()]); assert_eq!(tl.events().len(), 2); // b.jsonl no longer in the file list → its events must disappear. - tl.refresh(&[a.clone()]); + tl.refresh(std::slice::from_ref(&a)); let evs = tl.events(); assert_eq!(evs.len(), 1); assert_eq!(evs[0].file_path, PathBuf::from("/wt/a.rs"));