diff --git a/README.md b/README.md index f4a836b8..1daaebf5 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,16 @@ 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) | +| `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)* | 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). @@ -537,7 +548,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. | @@ -552,6 +563,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 +605,108 @@ 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 is a single line: the time and the file path. Long paths are abbreviated by collapsing the ancestor directories to their first letter, keeping the parent directory and filename readable (e.g. `docs/superpowers/specs/2026-06-05-foo.md` shows as `d/s/specs/2026-06-05-foo.md`). Press `Enter` on an entry (or click it) to open the **full-change detail modal**, a scrollable overlay showing the complete diff with a line-number gutter — added (`+`) lines are numbered with their current file line (the same line the editor opens to), while removed (`-`) lines show a blank gutter. + +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. + +#### Keyboard navigation + +The chronology bar is a focusable pane. While attached, press `Ctrl-x` then an arrow key **toward the bar's side** to move keyboard focus into it (bar on the right → `Ctrl-x →`; bar on the left → `Ctrl-x ←`). This only works from the edge pane adjacent to the bar; otherwise `Ctrl-x`+arrow keeps moving between agent split panes as normal. + +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 opens the full-change detail modal for that entry. +- `Esc` (or `Ctrl-x` + arrow **away** from the bar's side) returns focus to the agent pane. + +#### Detail modal + +The modal is a scrollable overlay showing the full diff of the selected change: + +- Scroll with `↑` / `↓`, `j` / `k`, `PgUp` / `PgDn`, `g` / `G`, or the mouse wheel. +- 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 | +| ---------- | ----------------------------------------------- | +| `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 focus the bar, select the entry, and open the detail modal. + +#### Opening a file at the changed line + +Pressing `e` inside the detail modal opens 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}`, `{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` → `+ ` + +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' +``` + +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 +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 + +`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 +906,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 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 00000000..f7844e1e --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-change-chronology-view.md @@ -0,0 +1,1982 @@ +# 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 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. + +--- + +## 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. 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 00000000..6e579580 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-chronology-keyboard-navigation.md @@ -0,0 +1,880 @@ +# 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. + +## 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). 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 00000000..891101fc --- /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. 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 00000000..36386cd0 --- /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. 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 00000000..a16295f7 --- /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. 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 00000000..f9688216 --- /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. 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 00000000..48ca7417 --- /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. 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 00000000..7506335c --- /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. 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 00000000..d8951b53 --- /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. 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 00000000..fa8602b1 --- /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. 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 00000000..b8c1492b --- /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. 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 00000000..a2cf251a --- /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. diff --git a/src/activity/chronology.rs b/src/activity/chronology.rs new file mode 100644 index 00000000..e7292dcd --- /dev/null +++ b/src/activity/chronology.rs @@ -0,0 +1,801 @@ +//! 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::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. +#[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, +} + +/// 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 { + /// 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, + /// Back-reference to the session log line for full re-extraction. + pub source: ChangeSource, +} + +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(), + } +} + +/// Bounded number of characters retained per side of a diff peek. +const DETAIL_MAX_CHARS: usize = 600; + +fn clip(s: &str, max: usize) -> String { + s.chars().take(max).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. +/// `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; + } + 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, detail_max), + }, + source: ChangeSource { + session_file: PathBuf::new(), + line_index: 0, + index_in_line: out.len(), + }, + }); + } + 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, detail_max), + new: clip(new, detail_max), + }, + source: ChangeSource { + session_file: PathBuf::new(), + line_index: 0, + index_in_line: out.len(), + }, + }); + } + } + } + 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, detail_max), + new: clip(new, detail_max), + }, + source: ChangeSource { + session_file: PathBuf::new(), + line_index: 0, + index_in_line: out.len(), + }, + }); + } + } + } + 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)) { + Some(decl) => truncate_summary(decl), + None => "new file".to_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, 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")); + 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, DETAIL_MAX_CHARS); + 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, DETAIL_MAX_CHARS); + 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, DETAIL_MAX_CHARS).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, DETAIL_MAX_CHARS).is_empty()); + } +} + +/// 1-based line to open the editor at, given the file's current contents and +/// 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 { new, .. } => new.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_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(), + }; + 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_new_string_falls_back_to_line_one() { + let detail = ChangeDetail::Edit { + old: "x".into(), + new: "nonexistent".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); + } +} + +/// 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_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 +} + +/// 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 { + 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) +} + +/// 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. 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; + } + + /// 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::*; + 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 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::*; + + #[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); + } +} + +#[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(std::slice::from_ref(&a)); + assert_eq!(tl.parse_count(), 1); + tl.refresh(std::slice::from_ref(&a)); // 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(std::slice::from_ref(&a)); + write_event(&a, "2026-05-14T19:00:00.000Z", "/wt/newer.rs"); + tl.refresh(std::slice::from_ref(&a)); + 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(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")); + } +} + +#[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()); + } + + #[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"), + } + } +} diff --git a/src/activity/mod.rs b/src/activity/mod.rs index 12920358..c929e023 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; diff --git a/src/agent/related.rs b/src/agent/related.rs index 13aabe02..30f1af3d 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/app.rs b/src/app.rs index 1731b7bf..0d466d94 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", } } } @@ -155,6 +158,36 @@ 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, + /// 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 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 + /// 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, + /// Keyboard focus is in the chronology bar (intercept nav keys). + pub chronology_focused: bool, + /// 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, + /// 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, @@ -304,6 +337,15 @@ 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_last_workspace: None, + chronology_entry_rects: Vec::new(), + chronology_bar_rect: None, + 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(), @@ -426,6 +468,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() } @@ -594,6 +650,26 @@ pub(crate) fn reset_detail_scroll_on_workspace_change( } } +/// 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, + chronology_sel: &mut usize, + chronology_focused: &mut bool, + last_workspace: &mut Option, + current: Option, +) { + if *last_workspace != current { + *scroll = 0; + *chronology_sel = 0; + *chronology_focused = false; + *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 @@ -671,6 +747,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") + } } }; @@ -921,6 +1004,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/app/input.rs b/src/app/input.rs index dec88b91..556b89e8 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -243,6 +243,126 @@ 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)) +} + +/// 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 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 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!( + "{} {}", + 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 +/// 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 { + 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. @@ -765,6 +885,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 = 0; + app.chronology_scroll = 0; + } + return Ok(()); + } if let View::Attached(state) = &mut app.view { state.focus_direction(arrow); } @@ -804,6 +957,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 @@ -898,6 +1059,34 @@ 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, len); + app.chronology_sel = new_sel; + match action { + NavAction::None => {} + NavAction::Exit => app.chronology_focused = false, + NavAction::Open(i) => open_change_modal(app, i), + } + } + // 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(); @@ -1488,6 +1677,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(()) } @@ -1676,6 +1951,60 @@ 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. + 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); + // 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.saturating_sub(3) + }; + app.chronology_scroll = + crate::ui::chronology_nav::clamp_scroll(target, len, visible); + 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 +2054,19 @@ async fn handle_mouse(app: &mut App, m: MouseEvent) { return; } - if let Some(idx) = app.chip_rects.iter().position(|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) + }) { + app.chronology_focused = true; + 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) && m.row >= r.y diff --git a/src/app/render.rs b/src/app/render.rs index aad6306d..42ce54c6 100644 --- a/src/app/render.rs +++ b/src/app/render.rs @@ -32,8 +32,46 @@ 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.chronology_bar_rect = None; 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 { + // 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); + } + } + } + match &app.view { crate::ui::View::Dashboard => { let selection_is_workspace = @@ -494,7 +532,75 @@ 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(); + // If the focused pane switched to a different workspace, drop any + // stale scroll offset / selection before reading them. + crate::app::reset_chronology_state_on_workspace_change( + &mut app.chronology_scroll, + &mut app.chronology_sel, + &mut app.chronology_focused, + &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, + app.chronology_visible_entries, + chronology_events.len(), + ); + } + let chronology_scroll = app.chronology_scroll; + + // 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, + focused: app.chronology_focused, + sel: app.chronology_sel, + }), + _ => None, + }; + 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; // The agent instance in the focused pane is the "active" one; the @@ -564,12 +670,16 @@ 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.chronology_visible_entries = out.chronology_visible_entries; + app.chronology_bar_rect = bar_rect; app.pinned_commands_cache = pinned; } crate::ui::View::AttachedPm => { @@ -631,6 +741,7 @@ pub fn draw(f: &mut ratatui::Frame, app: &mut App) { pinned, &[], None, + None, &app.theme, ); app.attached_pane_rects = out.pane_rects; @@ -735,6 +846,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), } } @@ -917,6 +1036,73 @@ 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: &[ratatui::text::Line<'static>], + 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| crate::ui::syntax::clip_line_to_width(l, inner.width as usize)) + .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()); + // 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 {}-{}/{}", + start, + 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/cli.rs b/src/cli.rs index d04e8844..43be8138 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() { diff --git a/src/commands/external.rs b/src/commands/external.rs index 51686c8d..d04a4873 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); @@ -198,6 +206,110 @@ 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`. +/// +/// 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, 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}")); + 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) +} + +/// 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 worktree_str = worktree.to_string_lossy(); + let file_str = file.to_string_lossy(); + let parts = resolve_editor_at_argv(&cmd, worktree_str.as_ref(), file_str.as_ref(), line)?; + 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()) @@ -393,4 +505,138 @@ 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", + "/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", "/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", "/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", "/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", "/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", "/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", "/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", "/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", "/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", "/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", "/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", "/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); + 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()) + ); + } } diff --git a/src/config/chronology.rs b/src/config/chronology.rs new file mode 100644 index 00000000..c39e0fd1 --- /dev/null +++ b/src/config/chronology.rs @@ -0,0 +1,233 @@ +//! 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, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Side { + Left, + #[default] + 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::*; + 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() { + 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 63d85c1d..5a2a0d6b 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 ba8bc5b6..b102cd70 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 1f5c208f..54c9bb48 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 9a3fe918..e406d04e 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,32 @@ 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 +1859,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 10f54ae2..1612e82f 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/attached.rs b/src/ui/attached.rs index 092a4c42..27870d50 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,27 @@ 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, + /// Keyboard focus is in the bar (drives the active header + selection highlight). + pub focused: bool, + /// In-pane cursor while focused. + 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)>, + /// 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. pub struct PanesDrawOutput { /// Clickable rects of the pinned-command chips (same as before). @@ -34,6 +56,54 @@ 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)>, + /// 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, +/// 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.saturating_sub(bar_cols), + ..area + }; + let bar = Rect { + x: area.x.saturating_add(area.width).saturating_sub(bar_cols), + width: bar_cols, + ..area + }; + (agent, Some(bar)) + } + Side::Left => { + let bar = Rect { + width: bar_cols, + ..area + }; + let agent = Rect { + x: area.x.saturating_add(bar_cols), + width: area.width.saturating_sub(bar_cols), + ..area + }; + (agent, Some(bar)) + } + } } /// Render one or more attached panes plus the shared chrome (optional @@ -68,6 +138,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 +151,14 @@ pub fn render_panes( render_dividers(f, dividers, theme); + let chronology_hits = match chronology_bar { + Some((bar_rect, draw)) => render_chronology_bar(f, bar_rect, &draw, theme), + None => ChronologyHits { + entries: Vec::new(), + visible_entries: 0, + }, + }; + if let Some(line) = attention_line { f.render_widget(Paragraph::new(line), status_area); } @@ -113,6 +192,159 @@ pub fn render_panes( chip_rects, pane_rects, agent_chip_rects, + chronology_entry_rects: chronology_hits.entries, + 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 +/// [`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, + bar_rect: Rect, + draw: &ChronologyDraw<'_>, + theme: &Theme, +) -> ChronologyHits { + if bar_rect.width == 0 || bar_rect.height == 0 { + return ChronologyHits { + entries: Vec::new(), + visible_entries: 0, + }; + } + + // 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 ChronologyHits { + entries: Vec::new(), + visible_entries: 0, + }; + } + 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 + }; + 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, 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 ChronologyHits { + entries: Vec::new(), + visible_entries: 0, + }; + } + + 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 ChronologyHits { + entries: Vec::new(), + visible_entries: 0, + }; + } + + let mut entry_rects: Vec<(usize, Rect)> = Vec::new(); + 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 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 { + 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, + }, + )); + visible_entries += 1; + cursor_y = cursor_y.saturating_add(drawn); + } + + ChronologyHits { + entries: entry_rects, + visible_entries, } } @@ -561,6 +793,55 @@ 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, + focused: false, + sel: 0, + }; + 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, + focused: false, + sel: 0, + }; + 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); diff --git a/src/ui/chronology_bar.rs b/src/ui/chronology_bar.rs new file mode 100644 index 00000000..7e3b22e4 --- /dev/null +++ b/src/ui/chronology_bar.rs @@ -0,0 +1,207 @@ +//! 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::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 +} + +/// 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) +} + +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. + 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}") +} + +/// 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 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), + ])] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::activity::chronology::{ChangeDetail, ChangeSource, 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(), + }, + source: ChangeSource::default(), + } + } + + #[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_is_a_single_header_line() { + let lines = entry_lines( + &ev("/wt/src/main.rs", "fn foo()"), + Path::new("/wt"), + 40, + false, + ); + assert_eq!(lines.len(), 1, "one row: the time+path header"); + } + + #[test] + fn selected_entry_reverses_its_spans() { + let lines = entry_lines( + &ev("/wt/src/main.rs", "fn foo()"), + Path::new("/wt"), + 40, + true, + ); + assert!( + lines[0] + .spans + .iter() + .all(|s| s.style.add_modifier.contains(Modifier::REVERSED)), + "selected row should be fully reversed" + ); + } + + #[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")); + } +} diff --git a/src/ui/chronology_nav.rs b/src/ui/chronology_nav.rs new file mode 100644 index 00000000..ebd20678 --- /dev/null +++ b/src/ui/chronology_nav.rs @@ -0,0 +1,126 @@ +//! 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`. + +/// 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, + Open(usize), + Exit, +} + +/// 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); + } + 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!(), + } +} + +/// 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 { + 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 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::*; + + #[test] + 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 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(1, NavKey::Top, 3), (0, NavAction::None)); + assert_eq!(nav(0, NavKey::Bottom, 3), (2, NavAction::None)); + } + + #[test] + 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(0, NavKey::Esc, 3).1, NavAction::Exit); + assert_eq!(nav(2, NavKey::Esc, 3).1, NavAction::Exit); + } + + #[test] + 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] + 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); + } +} diff --git a/src/ui/dashboard/tests.rs b/src/ui/dashboard/tests.rs index d3ffc1bd..3985ff3d 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, } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 533d5f81..7e400a80 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,8 +1,11 @@ pub mod attached; +pub mod chronology_bar; +pub mod chronology_nav; 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/modal.rs b/src/ui/modal.rs index 7b8bae7b..da7a73dc 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!( @@ -672,7 +683,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 +724,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(); diff --git a/src/ui/syntax.rs b/src/ui/syntax.rs new file mode 100644 index 00000000..8f0533a8 --- /dev/null +++ b/src/ui/syntax.rs @@ -0,0 +1,394 @@ +//! 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 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. +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 +} + +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()); + 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)]); + } +}