Compare commits
15 Commits
c3721bc9ef
...
5bc207c455
| Author | SHA1 | Date |
|---|---|---|
|
|
5bc207c455 | 2 months ago |
|
|
403762400e | 2 months ago |
|
|
277e3a8667 | 2 months ago |
|
|
e0398fd5bb | 2 months ago |
|
|
c4646cf0e2 | 2 months ago |
|
|
8d72b0e971 | 2 months ago |
|
|
9c9f61efd1 | 2 months ago |
|
|
970ed0895f | 2 months ago |
|
|
7f864192e8 | 2 months ago |
|
|
3dd463b3ae | 2 months ago |
|
|
f80185e442 | 2 months ago |
|
|
0581431dd7 | 2 months ago |
|
|
523bf4b89c | 2 months ago |
|
|
06b0c7cc57 | 2 months ago |
|
|
1ddf7fb4fe | 2 months ago |
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
# claudbg-2vwx
|
||||||
|
title: Filter queries
|
||||||
|
status: todo
|
||||||
|
type: epic
|
||||||
|
created_at: 2026-03-31T00:32:47Z
|
||||||
|
updated_at: 2026-03-31T00:32:47Z
|
||||||
|
---
|
||||||
|
|
||||||
|
Add filtering support to narrow session/agent list views. Covers the query language parser, CLI --filter flag, and TUI filter panel.
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
# claudbg-37cj
|
||||||
|
title: Add --[no-]color global flag and NO_COLOR env var support
|
||||||
|
status: todo
|
||||||
|
type: task
|
||||||
|
created_at: 2026-03-31T00:32:57Z
|
||||||
|
updated_at: 2026-03-31T00:32:57Z
|
||||||
|
parent: claudbg-qpfe
|
||||||
|
---
|
||||||
|
|
||||||
|
Add `--color` / `--no-color` as a global CLI flag (available on all commands). When not specified, auto-detect: enable color if stdout is a tty and NO_COLOR is unset/empty. Honor the NO_COLOR environment variable (any non-empty value → disable color), per the NO_COLOR spec. This flag controls all ANSI output in the CLI (transcripts etc).
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
# claudbg-4bms
|
||||||
|
title: Filter query parser
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-03-31T00:33:08Z
|
||||||
|
updated_at: 2026-03-31T05:04:41Z
|
||||||
|
parent: claudbg-2vwx
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement the filter query language:
|
||||||
|
|
||||||
|
**Syntax:**
|
||||||
|
- `key:value` — substring match (e.g. `model:haiku` matches `claude-haiku-4-5`)
|
||||||
|
- `key:*` — field is non-empty
|
||||||
|
- `key>value` / `key<value` — comparison for numeric and date fields
|
||||||
|
- `AND` / `OR` for combining expressions
|
||||||
|
- Multiple `--filter` flags are ANDed together
|
||||||
|
|
||||||
|
**Supported keys:**
|
||||||
|
- `model` — substring match on model name
|
||||||
|
- `project` — substring match on project path
|
||||||
|
- `id` — substring match on session/agent ID
|
||||||
|
- `agents` — count of sub-agent runs (numeric)
|
||||||
|
- `messages` — total message count (numeric, all roles)
|
||||||
|
- `tokens` — total token count (numeric)
|
||||||
|
- `date` — session start date (ISO 8601, e.g. `2026-03-20`)
|
||||||
|
|
||||||
|
**Errors:** malformed syntax or unknown key → fail with a user-readable error message.
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
Implemented the filter query language as a standalone module in `src/filter.rs`.
|
||||||
|
|
||||||
|
### What was built
|
||||||
|
|
||||||
|
- **`Filter` type** with `Filter::parse(s: &str) -> Result<Filter>` — hand-rolled recursive-descent parser, no new dependencies
|
||||||
|
- **`SessionRow` trait** with fields: `model`, `project`, `id`, `agents`, `messages`, `tokens`, `date`
|
||||||
|
- **`impl SessionRow for SessionListItem`** — wires to all `SessionListItem` fields; `tokens()` returns `None` (token count not yet tracked in that struct)
|
||||||
|
- **`Filter::matches(&self, row: &R) -> bool`** evaluates the parsed AST against any `SessionRow`
|
||||||
|
- **`pub use filter::Filter`** re-exported from `src/lib.rs`
|
||||||
|
|
||||||
|
### Parser details
|
||||||
|
|
||||||
|
- Tokenizer splits on whitespace; identifies `AND`/`OR` keywords and key-op-value atoms
|
||||||
|
- Recursive descent: `or_expr → and_expr ( OR and_expr )*`, `and_expr → primary ( AND primary )*`
|
||||||
|
- AND binds tighter than OR (standard precedence)
|
||||||
|
- String keys (`model`, `project`, `id`): `:` only; case-insensitive substring match; `*` matches any non-empty
|
||||||
|
- Numeric keys (`agents`, `messages`, `tokens`): `:` (equality), `>`, `<`
|
||||||
|
- Date key (`date`): ISO 8601 `YYYY-MM-DD`; `:` (equality), `>`, `<`
|
||||||
|
- Unknown keys and malformed syntax produce `AppError::Parse` with user-readable messages
|
||||||
|
|
||||||
|
### Not-yet-wired fields
|
||||||
|
|
||||||
|
- `tokens` — `SessionListItem` does not carry a token count; `tokens:`/`tokens>`/`tokens<` always returns `false` until the field is added
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
39 new unit tests in `filter::tests` covering: tokenizer, atom parser, `Filter::parse` (valid + error cases), and `Filter::matches` for all key types and logical operators. All 234 project tests pass.
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
# claudbg-4g3l
|
||||||
|
title: Limit list output to 10 by default with --limit flag
|
||||||
|
status: todo
|
||||||
|
type: feature
|
||||||
|
created_at: 2026-03-31T00:32:40Z
|
||||||
|
updated_at: 2026-03-31T00:32:40Z
|
||||||
|
---
|
||||||
|
|
||||||
|
Both `sessions list` and `agents list <session-id>` should default to showing only the 10 most recent entries (sorted most-recent-first). Add a `--limit=<N|all>` flag to override: accepts an integer (e.g. `--limit=50`) or the keyword `all` to show everything.
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
# claudbg-6gx6
|
||||||
|
title: 'TUI: color toggle with ''c'' key'
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-03-31T00:33:01Z
|
||||||
|
updated_at: 2026-03-31T04:32:25Z
|
||||||
|
parent: claudbg-qpfe
|
||||||
|
---
|
||||||
|
|
||||||
|
In the TUI, pressing `c` toggles color coding on/off globally (affects all transcript views). Color is on by default unless NO_COLOR is set. The toggle state persists for the duration of the TUI session.
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
Added a global color toggle to the TUI, activated with the `c` key:
|
||||||
|
|
||||||
|
- **`src/tui/state.rs`**: Added `color_enabled: bool` field to `AppState`. Initialised in `AppState::new()` by checking the `NO_COLOR` env var (false if set and non-empty, true otherwise). Added tests for both cases.
|
||||||
|
- **`src/tui/run.rs`**: Added `KeyCode::Char('c')` to the global event fallback handler to toggle `state.color_enabled`. Added a test for the toggle.
|
||||||
|
- **`src/tui/screens/transcript.rs`**: Updated `build_chat_lines` to accept a `color_enabled: bool` parameter and conditionally apply ratatui `Style` colours to role labels (orange for assistant, grey for user), tool-use lines (cyan), tool-result lines (green/red), and image lines (yellow). When `color_enabled` is false, all spans use `Style::default()`. Updated the call site in `render_transcript` to pass `state.color_enabled`. Updated all test calls to pass `false`.
|
||||||
|
- **`src/tui/modals/help_modal.rs`**: Added `c toggle color` line to `HELP_TEXT` and bumped `DIALOG_HEIGHT` from 14 to 15 to accommodate it.
|
||||||
@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
# claudbg-8vpb
|
# claudbg-8vpb
|
||||||
title: Transcription output improvements
|
title: Transcription output improvements
|
||||||
status: todo
|
status: completed
|
||||||
type: epic
|
type: epic
|
||||||
|
priority: normal
|
||||||
created_at: 2026-03-30T04:43:30Z
|
created_at: 2026-03-30T04:43:30Z
|
||||||
updated_at: 2026-03-30T04:43:30Z
|
updated_at: 2026-03-30T17:02:41Z
|
||||||
---
|
---
|
||||||
|
|
||||||
Groups bugs in `sessions transcribe` and `agents transcribe` output: tools display format, duration miscalculation, tool result visibility defaults, and tool input truncation.
|
Groups bugs in `sessions transcribe` and `agents transcribe` output: tools display format, duration miscalculation, tool result visibility defaults, and tool input truncation.
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
# claudbg-d8ht
|
||||||
|
title: Color-coded transcript output in CLI
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-03-31T00:32:52Z
|
||||||
|
updated_at: 2026-03-31T04:28:11Z
|
||||||
|
parent: claudbg-qpfe
|
||||||
|
---
|
||||||
|
|
||||||
|
Apply ANSI colors to transcript output in `sessions transcribe` and `agents transcribe`:
|
||||||
|
- `[assistant]` → orange
|
||||||
|
- `[user]` → grey
|
||||||
|
- `[tool: Foo]` → blue
|
||||||
|
- `[tool_result]` → green
|
||||||
|
- `[tool_result (error)]` → red
|
||||||
|
|
||||||
|
Colors are enabled by default in interactive terminals (isatty check). Controlled by the --[no-]color flag and NO_COLOR env var.
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
Added ANSI color support to transcript output in both `sessions transcribe` and `agents transcribe`.
|
||||||
|
|
||||||
|
### New file: `src/output/color.rs`
|
||||||
|
A small color helper module with five functions — `orange`, `grey`, `blue`, `green`, `red` — each taking a `&str` and a `color_enabled: bool` flag. When disabled the string is returned unchanged; when enabled the string is wrapped with the appropriate ANSI 256-color (or standard) escape code followed by `\x1b[0m` reset. Includes 3 unit tests.
|
||||||
|
|
||||||
|
### Modified: `src/output/mod.rs`
|
||||||
|
Declared and exported the new `color` sub-module.
|
||||||
|
|
||||||
|
### Modified: `src/commands/sessions.rs` and `src/commands/agents.rs`
|
||||||
|
Updated `render_entry_text` in both files (they are independent copies) to:
|
||||||
|
- Call `opts.color_enabled()` once and pass the flag to color helpers.
|
||||||
|
- Color `[assistant]` labels orange (`\x1b[38;5;208m`).
|
||||||
|
- Color `[user]` labels grey (`\x1b[38;5;245m`).
|
||||||
|
- Color `[tool: X]` labels blue (`\x1b[38;5;33m`).
|
||||||
|
- Color `[tool_result]` labels green (`\x1b[32m`).
|
||||||
|
- Color `[tool_result (error)]` labels red (`\x1b[31m`).
|
||||||
|
|
||||||
|
Only the label prefix is colored; the message body is left plain. No new crate dependencies were added.
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
# claudbg-gf58
|
||||||
|
title: 'TUI: filter panel with persistent history'
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-03-31T00:33:21Z
|
||||||
|
updated_at: 2026-03-31T16:49:05Z
|
||||||
|
parent: claudbg-2vwx
|
||||||
|
blocked_by:
|
||||||
|
- claudbg-4bms
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a filter text box panel at the bottom of the TUI screen (visible on sessions list and agents list screens).
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Press `t` to focus the filter input directly; Tab cycles to it among panels
|
||||||
|
- Type a filter query and press Enter to apply it to the current list
|
||||||
|
- Escape clears the text input
|
||||||
|
- Up/Down arrow keys cycle through scroll-back history of previous queries
|
||||||
|
- After pressing Enter, focus returns to the main list panel
|
||||||
|
- The filter does not apply to transcript screens (transcripts cannot be filtered)
|
||||||
|
|
||||||
|
**History persistence:**
|
||||||
|
- History is saved to and loaded from `~/.claude/claudbg.tui.history` (one query per line)
|
||||||
|
- History persists across TUI sessions
|
||||||
|
|
||||||
|
Depends on the filter query parser.
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
**`src/tui/state.rs`**
|
||||||
|
- Added `FilterInput` variant to the `Focus` enum
|
||||||
|
- Added filter state fields to `AppState`: `filter_input`, `filter_active`, `filter_history`, `filter_history_pos`
|
||||||
|
- Added `history_file_path()` helper (resolves to `~/.claude/claudbg.tui.history`)
|
||||||
|
- Added `load_history()` to load history lines from disk at TUI startup
|
||||||
|
- Added `append_history_to_disk()` to append a single query in append mode (no full rewrite)
|
||||||
|
- Updated `AppState::new()` to load history and initialize filter fields
|
||||||
|
|
||||||
|
**`src/tui/screens/session_list.rs`**
|
||||||
|
- Added imports for `Direction`, `Layout`, `Color`, `Span`, `Line`, `Paragraph`, `Focus`, `Filter`
|
||||||
|
- Rewrote `render_session_list` to split area: top for table, bottom 3 rows for filter bar
|
||||||
|
- Added `render_filter_bar` function: shows "Filter: " label + input text + cursor when focused
|
||||||
|
- Filter bar highlights border in yellow when focused, shows placeholder hint when unfocused/empty
|
||||||
|
- Sessions table applies active filter via `Filter::parse` + `Filter::matches`
|
||||||
|
- Table title shows active filter query when one is set
|
||||||
|
- Added `filtered_session_indices` helper for navigation with filtered rows
|
||||||
|
- Updated `handle_session_list_event`: when `Focus::FilterInput`, dispatches to `handle_filter_input_event`
|
||||||
|
- Added `t` key and Tab to focus filter input; Tab from filter returns focus to list
|
||||||
|
- Updated Down/Enter to work with filtered session indices
|
||||||
|
- Added `handle_filter_input_event` handling: char input, Backspace, Enter (apply + return focus), Escape (clear input, stay focused), Up/Down for history browsing, Tab (cycle focus)
|
||||||
|
|
||||||
|
**`src/tui/screens/transcript.rs`**
|
||||||
|
- Added `Focus::FilterInput` to Tab and Up/Down match arms (treated same as ChatLog on transcript screen)
|
||||||
|
|
||||||
|
**`src/cli.rs`**
|
||||||
|
- Fixed pre-existing clippy warning: replaced `map_or(false, ...)` with `is_ok_and(...)`
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
# claudbg-horp
|
||||||
|
title: 'CLI: --filter flag on sessions list and agents list'
|
||||||
|
status: completed
|
||||||
|
type: task
|
||||||
|
priority: normal
|
||||||
|
created_at: 2026-03-31T00:33:14Z
|
||||||
|
updated_at: 2026-03-31T05:14:11Z
|
||||||
|
parent: claudbg-2vwx
|
||||||
|
blocked_by:
|
||||||
|
- claudbg-4bms
|
||||||
|
---
|
||||||
|
|
||||||
|
Add `--filter <query>` flag to `sessions list` and `agents list`. The flag may be passed multiple times; multiple values are ANDed together (equivalent to joining with ` AND `). Depends on the filter query parser.
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
Added `--filter <query>` flag to `sessions list` and `agents list`.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- **`src/cli.rs`**: Added `filter: Vec<String>` field with `#[arg(long, action = clap::ArgAction::Append)]` to both `SessionsCmd::List` and `AgentsCmd::List`.
|
||||||
|
|
||||||
|
- **`src/main.rs`**: Destructure and pass `filter` from both list variants through to the command functions. Default for the bare `sessions` command (no subcommand) is `vec![]`.
|
||||||
|
|
||||||
|
- **`src/commands/sessions.rs`**: Added private `RawSessionRow` struct implementing `crate::filter::SessionRow`. The `list` function now accepts `filters: Vec<String>`, parses them all up front with `Filter::parse` (returning errors immediately), collects raw DB rows into `RawSessionRow` values, applies all filters (AND semantics), and then converts passing rows to display strings before rendering.
|
||||||
|
|
||||||
|
- **`src/commands/agents.rs`**: Added private `AgentRowRef<'a>` wrapper around `&AgentRef` implementing `crate::filter::SessionRow` (id → agent_id, model → agent_type, date → modified_at, others zero/empty). The `list` function now accepts `filters: Vec<String>`, parses them, and filters the `agents` vec before building display rows.
|
||||||
|
|
||||||
|
- Updated all existing test call sites in `sessions.rs` and `agents.rs` to pass `vec![]` for the new `filters` parameter.
|
||||||
|
|
||||||
|
All 234 tests pass.
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
# claudbg-ltt0
|
||||||
|
title: 'sessions: no sub-command defaults to sessions list'
|
||||||
|
status: todo
|
||||||
|
type: feature
|
||||||
|
created_at: 2026-03-31T00:32:35Z
|
||||||
|
updated_at: 2026-03-31T00:32:35Z
|
||||||
|
---
|
||||||
|
|
||||||
|
Running `claudbg sessions` with no sub-command should behave identically to `claudbg sessions list`. Note: `claudbg agents` does NOT get this treatment since agents list requires a session ID.
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
# claudbg-qpfe
|
||||||
|
title: Transcript color coding
|
||||||
|
status: todo
|
||||||
|
type: epic
|
||||||
|
created_at: 2026-03-31T00:32:44Z
|
||||||
|
updated_at: 2026-03-31T00:32:44Z
|
||||||
|
---
|
||||||
|
|
||||||
|
Color-code transcript output to visually distinguish message types. Covers CLI color output, --[no-]color global flag, NO_COLOR env var, and TUI color toggle.
|
||||||
@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
# claudbg-tci9
|
# claudbg-tci9
|
||||||
title: Session list & dump improvements
|
title: Session list & dump improvements
|
||||||
status: todo
|
status: completed
|
||||||
type: epic
|
type: epic
|
||||||
|
priority: normal
|
||||||
created_at: 2026-03-30T04:34:16Z
|
created_at: 2026-03-30T04:34:16Z
|
||||||
updated_at: 2026-03-30T04:34:16Z
|
updated_at: 2026-03-30T17:02:41Z
|
||||||
---
|
---
|
||||||
|
|
||||||
Groups bugs and improvements to the `sessions list` and `sessions dump` commands: table overflow, JSON output format, --include help text, duplicate row collapsing, and sub-agent count.
|
Groups bugs and improvements to the `sessions list` and `sessions dump` commands: table overflow, JSON output format, --include help text, duplicate row collapsing, and sub-agent count.
|
||||||
|
|||||||
@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
# claudbg-zi1d
|
||||||
|
title: TUI session list shows no sessions — state.sessions never populated on startup
|
||||||
|
status: completed
|
||||||
|
type: bug
|
||||||
|
priority: high
|
||||||
|
created_at: 2026-03-30T17:05:02Z
|
||||||
|
updated_at: 2026-03-30T21:02:18Z
|
||||||
|
parent: claudbg-i6l2
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The TUI session list is always empty. `run_tui()` in `src/tui/run.rs` creates `AppState::new()` (which has `sessions: Vec::new()`) and enters the event loop without ever loading sessions from disk.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
`discover_sessions()` in `src/parser/discovery.rs` is never called from the TUI startup path. The session list screen renders from `state.sessions` which stays empty forever.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
Before entering the event loop in `run_tui()`, call `discover_sessions()` and convert each `SessionRef` into a `SessionListItem`, then populate `state.sessions`.
|
||||||
|
|
||||||
|
### SessionRef → SessionListItem mapping
|
||||||
|
- `short_id` = first 8 chars of `session_id`
|
||||||
|
- `full_id` = `session_id`
|
||||||
|
- `date` = `modified_at.format("%Y-%m-%d %H:%M:%S")`
|
||||||
|
- `project` = `project_path.unwrap_or_default()`
|
||||||
|
- `model` = `""` (leave empty — would require parsing JSONL)
|
||||||
|
- `msg_count` = 0 (leave as 0 for now)
|
||||||
|
- `agent_count` = call `discover_agents_for_session(&session_ref.file_path).map(|v| v.len()).unwrap_or(0)`
|
||||||
|
|
||||||
|
Sort by `modified_at` descending (most recent first).
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
If `discover_sessions()` fails, start with an empty list (don't crash). Log the error to stderr before entering the alternate screen, or silently ignore.
|
||||||
|
|
||||||
|
## Relevant files
|
||||||
|
- `src/tui/run.rs` — `run_tui()` function, add session loading before the event loop
|
||||||
|
- `src/parser/discovery.rs` — `discover_sessions()` and `discover_agents_for_session()`
|
||||||
|
- `src/tui/state.rs` — `SessionListItem` struct
|
||||||
|
|
||||||
|
## Summary of Changes\n\nIn run_tui(), before the event loop: call discover_sessions() (silently empty on error), sort by modified_at descending, map each SessionRef to SessionListItem (short_id=8 chars, full_id, date=formatted timestamp, project=cwd, model/msg_count=empty/0, agent_count from discover_agents_for_session). Assign to state.sessions.
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
* Running `claudbg sessions` with no sub-command should list sessions, like `claudbg sessions list`
|
||||||
|
* Running listing sessions and agent sessions should only show the 10 latest ones.
|
||||||
|
* The flag `--limit=<int|all>` allows setting that limit to a custom integer, or the keyword `all` to show all
|
||||||
|
* Session and agent session transcripts should color-code tool use and output to make it visually clearer
|
||||||
|
* `[assistant]` should be orange
|
||||||
|
* `[user]` should be grey
|
||||||
|
* `[tool: Foo]` should be blue
|
||||||
|
* `[tool_result]` should be green
|
||||||
|
* `[tool_result (error)]` should be red
|
||||||
|
* Accept the `--[no-]color` flag to enable/disable color (enabled by default in interactive terminals)
|
||||||
|
* Color coding should be enabled by default in the TUI with `c` toggling it off/on
|
||||||
|
* Honor the NO_COLOR environment variable for both the CLI and TUI
|
||||||
|
* Plan and ticket the new feature @./specs/FILTER.md
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
# Filter Queries
|
||||||
|
|
||||||
|
We want basic filtering to be able to narrow our view.
|
||||||
|
Use cases include only showing sesisons...
|
||||||
|
* with sub-agents
|
||||||
|
* with a certain minimum/maximum of messages
|
||||||
|
* in a given project
|
||||||
|
* with a given model
|
||||||
|
* between a given date range
|
||||||
|
|
||||||
|
Proposed query language is a simple `key:value` for static single values, with support for globbing and by default fuzzy searching.
|
||||||
|
Examples:
|
||||||
|
* `model:haiku` equivalent to `model:*haiku*`
|
||||||
|
* `project:my-org/my-project` ~= `project:*my-org/my-project`
|
||||||
|
* `project:*` project is non-empty
|
||||||
|
|
||||||
|
Also required is support for `>` and `<` to support ranges
|
||||||
|
Examples:
|
||||||
|
* `agents>0`
|
||||||
|
* `messages<10`
|
||||||
|
* `date>2026-03-20`
|
||||||
|
|
||||||
|
We also need to support `AND` and `OR` for logical combinations
|
||||||
|
Examples:
|
||||||
|
* `date>2026-03-15 AND date<2026-03-20` sessions between 03-15 and 03-20.
|
||||||
|
|
||||||
|
When a query uses a malformed syntax or a key which is not found, the command should fail to evaluate with an error displayed to the user.
|
||||||
|
|
||||||
|
<cli>
|
||||||
|
This integrates into the CLI by adding a `--filter` flag which accepts a string filter query.
|
||||||
|
example: `claudbg sessions list --filter 'agents>0'` lists all session with sub-agents.
|
||||||
|
The `--filter` query can be passed multiple times, resulting in the queryies being combined into with AND
|
||||||
|
example: `caudbg sesions list --filter 'agents>0' --filter 'messages>100'` is equivalent to `claudbg sessions list --filter 'agents>0 AND messages>100'`
|
||||||
|
</cli>
|
||||||
|
|
||||||
|
<tui>
|
||||||
|
The TUI should include a text box panel at the bottom of the sceen wich accepts a filter.
|
||||||
|
When the user presses enter this query is run on the current main panel (sessions or agents).
|
||||||
|
Pressing escape clears the text input.
|
||||||
|
Pressing up/down arrows cycles through a scroll-back history.
|
||||||
|
Users cycle through panels with Tab to get to this, or press `t` to navigate directly to the text input.
|
||||||
|
When users press enter they are navigated to the main panel so they can interact with the filtered data.
|
||||||
|
This text box does not (yet) interact with session/sub-agent transcripts as these objects cannot be filtered.
|
||||||
|
</tui>
|
||||||
@ -0,0 +1,781 @@
|
|||||||
|
//! Filter query language for session filtering.
|
||||||
|
//!
|
||||||
|
//! Implements the filter query syntax described in `specs/FILTER.md`.
|
||||||
|
//!
|
||||||
|
//! # Syntax
|
||||||
|
//!
|
||||||
|
//! - `key:value` — case-insensitive substring match
|
||||||
|
//! - `key:*` — field is non-empty (any value)
|
||||||
|
//! - `key>value` — greater-than comparison (numeric / date fields)
|
||||||
|
//! - `key<value` — less-than comparison (numeric / date fields)
|
||||||
|
//! - `expr AND expr` — logical AND (binds tighter than OR)
|
||||||
|
//! - `expr OR expr` — logical OR
|
||||||
|
//!
|
||||||
|
//! # Supported keys
|
||||||
|
//!
|
||||||
|
//! | Key | Type | Operations |
|
||||||
|
//! |------------|---------|--------------------|
|
||||||
|
//! | `model` | string | `:` (substring) |
|
||||||
|
//! | `project` | string | `:` (substring) |
|
||||||
|
//! | `id` | string | `:` (substring) |
|
||||||
|
//! | `agents` | numeric | `:`, `>`, `<` |
|
||||||
|
//! | `messages` | numeric | `:`, `>`, `<` |
|
||||||
|
//! | `tokens` | numeric | `:`, `>`, `<` |
|
||||||
|
//! | `date` | date | `:`, `>`, `<` |
|
||||||
|
//!
|
||||||
|
//! # Not-yet-wired fields
|
||||||
|
//!
|
||||||
|
//! - `tokens` — the [`SessionRow`] data available from [`crate::tui::state::SessionListItem`]
|
||||||
|
//! does not yet carry a total token count; `tokens:` / `tokens>` / `tokens<` filters will
|
||||||
|
//! always return `false` until the field is added to `SessionListItem`.
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public data type consumed by Filter::matches
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A view of a session row that a [`Filter`] can be evaluated against.
|
||||||
|
///
|
||||||
|
/// Implement this trait for any type that represents a session list row.
|
||||||
|
/// A blanket implementation is provided for [`crate::tui::state::SessionListItem`].
|
||||||
|
pub trait SessionRow {
|
||||||
|
/// Model identifier string (e.g. `"claude-haiku-4-5"`).
|
||||||
|
fn model(&self) -> &str;
|
||||||
|
/// Project path string.
|
||||||
|
fn project(&self) -> &str;
|
||||||
|
/// Session ID (full UUID or short prefix).
|
||||||
|
fn id(&self) -> &str;
|
||||||
|
/// Number of sub-agent runs attached to this session.
|
||||||
|
fn agents(&self) -> u64;
|
||||||
|
/// Total message count (all roles).
|
||||||
|
fn messages(&self) -> u64;
|
||||||
|
/// Total token count.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the token count is not available for this row type.
|
||||||
|
fn tokens(&self) -> Option<u64>;
|
||||||
|
/// Session date.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the date cannot be parsed from the row.
|
||||||
|
fn date(&self) -> Option<NaiveDate>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionRow for crate::tui::state::SessionListItem {
|
||||||
|
fn model(&self) -> &str {
|
||||||
|
&self.model
|
||||||
|
}
|
||||||
|
fn project(&self) -> &str {
|
||||||
|
&self.project
|
||||||
|
}
|
||||||
|
fn id(&self) -> &str {
|
||||||
|
&self.full_id
|
||||||
|
}
|
||||||
|
fn agents(&self) -> u64 {
|
||||||
|
self.agent_count as u64
|
||||||
|
}
|
||||||
|
fn messages(&self) -> u64 {
|
||||||
|
self.msg_count as u64
|
||||||
|
}
|
||||||
|
/// Token count is not yet tracked in `SessionListItem`; always returns `None`.
|
||||||
|
fn tokens(&self) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn date(&self) -> Option<NaiveDate> {
|
||||||
|
// `date` field is e.g. "2026-03-30 14:22:01"; parse just the date portion.
|
||||||
|
self.date.get(..10).and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal AST
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A comparison operator.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum Op {
|
||||||
|
/// `:` — substring match (strings) or equality (numeric/date).
|
||||||
|
Colon,
|
||||||
|
/// `>` — greater-than.
|
||||||
|
Gt,
|
||||||
|
/// `<` — less-than.
|
||||||
|
Lt,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A supported filter key.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum Key {
|
||||||
|
Model,
|
||||||
|
Project,
|
||||||
|
Id,
|
||||||
|
Agents,
|
||||||
|
Messages,
|
||||||
|
Tokens,
|
||||||
|
Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Key {
|
||||||
|
fn parse(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"model" => Some(Key::Model),
|
||||||
|
"project" => Some(Key::Project),
|
||||||
|
"id" => Some(Key::Id),
|
||||||
|
"agents" => Some(Key::Agents),
|
||||||
|
"messages" => Some(Key::Messages),
|
||||||
|
"tokens" => Some(Key::Tokens),
|
||||||
|
"date" => Some(Key::Date),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single key-op-value predicate node.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
struct Predicate {
|
||||||
|
key: Key,
|
||||||
|
op: Op,
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter AST node — either a leaf predicate or a logical combination.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
enum Expr {
|
||||||
|
Pred(Predicate),
|
||||||
|
And(Box<Expr>, Box<Expr>),
|
||||||
|
Or(Box<Expr>, Box<Expr>),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tokenizer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Tokens produced by the lexer.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
enum Token {
|
||||||
|
/// A bare word (key name or value) with no operators embedded.
|
||||||
|
Word(String),
|
||||||
|
/// `AND` keyword.
|
||||||
|
And,
|
||||||
|
/// `OR` keyword.
|
||||||
|
Or,
|
||||||
|
/// A complete `key:value`, `key>value`, or `key<value` atom (as a raw string).
|
||||||
|
Atom(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lex `input` into a sequence of [`Token`]s.
|
||||||
|
///
|
||||||
|
/// Tokenization rules:
|
||||||
|
/// - Split on whitespace.
|
||||||
|
/// - Tokens that contain `:`, `>`, or `<` are emitted as `Token::Atom`.
|
||||||
|
/// - `AND` / `OR` (exact case) are emitted as the corresponding keyword tokens.
|
||||||
|
/// - Everything else is a `Token::Word`.
|
||||||
|
fn tokenize(input: &str) -> Vec<Token> {
|
||||||
|
input
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|chunk| match chunk {
|
||||||
|
"AND" => Token::And,
|
||||||
|
"OR" => Token::Or,
|
||||||
|
s if s.contains(':') || s.contains('>') || s.contains('<') => {
|
||||||
|
Token::Atom(s.to_string())
|
||||||
|
}
|
||||||
|
s => Token::Word(s.to_string()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Atom parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse a raw atom string (e.g. `"model:haiku"`) into a [`Predicate`].
|
||||||
|
fn parse_atom(atom: &str) -> Result<Predicate> {
|
||||||
|
// Find the first occurrence of `:`, `>`, or `<`.
|
||||||
|
let (op_pos, op) = atom
|
||||||
|
.char_indices()
|
||||||
|
.find_map(|(i, c)| match c {
|
||||||
|
':' => Some((i, Op::Colon)),
|
||||||
|
'>' => Some((i, Op::Gt)),
|
||||||
|
'<' => Some((i, Op::Lt)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.ok_or_else(|| AppError::Parse(format!("expected operator in '{atom}'")))?;
|
||||||
|
|
||||||
|
let key_str = &atom[..op_pos];
|
||||||
|
let value_str = &atom[op_pos + 1..];
|
||||||
|
|
||||||
|
if key_str.is_empty() {
|
||||||
|
return Err(AppError::Parse(format!(
|
||||||
|
"missing key before operator in '{atom}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = Key::parse(key_str).ok_or_else(|| {
|
||||||
|
AppError::Parse(format!(
|
||||||
|
"unknown filter key '{key_str}' — valid keys: model, project, id, agents, messages, tokens, date"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// For string keys (model/project/id) only `:` is allowed.
|
||||||
|
if matches!(key, Key::Model | Key::Project | Key::Id)
|
||||||
|
&& !matches!(op, Op::Colon)
|
||||||
|
{
|
||||||
|
return Err(AppError::Parse(format!(
|
||||||
|
"key '{key_str}' only supports ':' operator"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Predicate {
|
||||||
|
key,
|
||||||
|
op,
|
||||||
|
value: value_str.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recursive-descent expression parser
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Grammar (precedence: AND binds tighter than OR):
|
||||||
|
//
|
||||||
|
// expr = or_expr
|
||||||
|
// or_expr = and_expr ( OR and_expr )*
|
||||||
|
// and_expr = primary ( AND primary )*
|
||||||
|
// primary = Atom
|
||||||
|
//
|
||||||
|
// A `primary` is any token that is *not* `AND` or `OR` (i.e. an atom).
|
||||||
|
|
||||||
|
struct Parser {
|
||||||
|
tokens: Vec<Token>,
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parser {
|
||||||
|
fn new(tokens: Vec<Token>) -> Self {
|
||||||
|
Parser { tokens, pos: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek(&self) -> Option<&Token> {
|
||||||
|
self.tokens.get(self.pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance(&mut self) -> Option<Token> {
|
||||||
|
let tok = self.tokens.get(self.pos).cloned();
|
||||||
|
self.pos += 1;
|
||||||
|
tok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an `or_expr`.
|
||||||
|
fn parse_or(&mut self) -> Result<Expr> {
|
||||||
|
let mut left = self.parse_and()?;
|
||||||
|
while self.peek() == Some(&Token::Or) {
|
||||||
|
self.advance(); // consume OR
|
||||||
|
let right = self.parse_and()?;
|
||||||
|
left = Expr::Or(Box::new(left), Box::new(right));
|
||||||
|
}
|
||||||
|
Ok(left)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an `and_expr`.
|
||||||
|
fn parse_and(&mut self) -> Result<Expr> {
|
||||||
|
let mut left = self.parse_primary()?;
|
||||||
|
while self.peek() == Some(&Token::And) {
|
||||||
|
self.advance(); // consume AND
|
||||||
|
let right = self.parse_primary()?;
|
||||||
|
left = Expr::And(Box::new(left), Box::new(right));
|
||||||
|
}
|
||||||
|
Ok(left)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a primary (atom).
|
||||||
|
fn parse_primary(&mut self) -> Result<Expr> {
|
||||||
|
match self.advance() {
|
||||||
|
Some(Token::Atom(s)) => Ok(Expr::Pred(parse_atom(&s)?)),
|
||||||
|
Some(Token::Word(w)) => {
|
||||||
|
// A bare word without operator is a syntax error.
|
||||||
|
Err(AppError::Parse(format!(
|
||||||
|
"unexpected token '{w}' — expected a filter expression like 'key:value'"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Some(Token::And) => Err(AppError::Parse(
|
||||||
|
"unexpected 'AND' — expected a filter expression".to_string(),
|
||||||
|
)),
|
||||||
|
Some(Token::Or) => Err(AppError::Parse(
|
||||||
|
"unexpected 'OR' — expected a filter expression".to_string(),
|
||||||
|
)),
|
||||||
|
None => Err(AppError::Parse(
|
||||||
|
"unexpected end of filter — expected a filter expression".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(mut self) -> Result<Expr> {
|
||||||
|
if self.tokens.is_empty() {
|
||||||
|
return Err(AppError::Parse("filter expression is empty".to_string()));
|
||||||
|
}
|
||||||
|
let expr = self.parse_or()?;
|
||||||
|
// If there are leftover tokens that were not consumed, that is a syntax error.
|
||||||
|
if self.pos < self.tokens.len() {
|
||||||
|
let leftover: Vec<_> = self.tokens[self.pos..]
|
||||||
|
.iter()
|
||||||
|
.map(|t| match t {
|
||||||
|
Token::Atom(s) | Token::Word(s) => s.clone(),
|
||||||
|
Token::And => "AND".to_string(),
|
||||||
|
Token::Or => "OR".to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
return Err(AppError::Parse(format!(
|
||||||
|
"unexpected tokens after expression: {}",
|
||||||
|
leftover.join(" ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(expr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filter — public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A parsed filter expression that can be evaluated against session rows.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use claudbg::filter::Filter;
|
||||||
|
/// let f = Filter::parse("model:haiku AND messages>10").unwrap();
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Filter {
|
||||||
|
expr: Expr,
|
||||||
|
/// The original query string, preserved for display / debugging.
|
||||||
|
pub raw: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Filter {
|
||||||
|
/// Parse a filter query string into a [`Filter`].
|
||||||
|
///
|
||||||
|
/// Returns an [`crate::error::AppError::Parse`] error with a user-readable
|
||||||
|
/// message if the query is malformed or references an unknown key.
|
||||||
|
pub fn parse(s: &str) -> Result<Self> {
|
||||||
|
let tokens = tokenize(s);
|
||||||
|
let expr = Parser::new(tokens).parse()?;
|
||||||
|
Ok(Filter {
|
||||||
|
expr,
|
||||||
|
raw: s.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate the filter against a session row.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the row matches the filter expression.
|
||||||
|
pub fn matches<R: SessionRow>(&self, row: &R) -> bool {
|
||||||
|
eval_expr(&self.expr, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Expression evaluator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn eval_expr<R: SessionRow>(expr: &Expr, row: &R) -> bool {
|
||||||
|
match expr {
|
||||||
|
Expr::Pred(p) => eval_pred(p, row),
|
||||||
|
Expr::And(l, r) => eval_expr(l, row) && eval_expr(r, row),
|
||||||
|
Expr::Or(l, r) => eval_expr(l, row) || eval_expr(r, row),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval_pred<R: SessionRow>(pred: &Predicate, row: &R) -> bool {
|
||||||
|
match &pred.key {
|
||||||
|
// ── String keys ─────────────────────────────────────────────────────
|
||||||
|
Key::Model => eval_string(row.model(), &pred.value),
|
||||||
|
Key::Project => eval_string(row.project(), &pred.value),
|
||||||
|
Key::Id => eval_string(row.id(), &pred.value),
|
||||||
|
|
||||||
|
// ── Numeric keys ────────────────────────────────────────────────────
|
||||||
|
Key::Agents => eval_numeric(row.agents(), &pred.op, &pred.value),
|
||||||
|
Key::Messages => eval_numeric(row.messages(), &pred.op, &pred.value),
|
||||||
|
Key::Tokens => match row.tokens() {
|
||||||
|
Some(v) => eval_numeric(v, &pred.op, &pred.value),
|
||||||
|
// Token count not available for this row type — filter does not match.
|
||||||
|
None => false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Date key ────────────────────────────────────────────────────────
|
||||||
|
Key::Date => match row.date() {
|
||||||
|
Some(d) => eval_date(d, &pred.op, &pred.value),
|
||||||
|
None => false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate a string predicate.
|
||||||
|
///
|
||||||
|
/// `*` matches any non-empty string.
|
||||||
|
/// Otherwise performs a case-insensitive substring match.
|
||||||
|
fn eval_string(field: &str, value: &str) -> bool {
|
||||||
|
if value == "*" {
|
||||||
|
return !field.is_empty();
|
||||||
|
}
|
||||||
|
field.to_lowercase().contains(&value.to_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate a numeric predicate.
|
||||||
|
///
|
||||||
|
/// For `:` the value is compared for equality.
|
||||||
|
/// For `>` / `<` the value is parsed and compared.
|
||||||
|
/// Returns `false` on parse failure.
|
||||||
|
fn eval_numeric(field: u64, op: &Op, value: &str) -> bool {
|
||||||
|
let Ok(v) = value.parse::<u64>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match op {
|
||||||
|
Op::Colon => field == v,
|
||||||
|
Op::Gt => field > v,
|
||||||
|
Op::Lt => field < v,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate a date predicate.
|
||||||
|
///
|
||||||
|
/// `value` must be an ISO 8601 date string (`YYYY-MM-DD`).
|
||||||
|
/// Returns `false` on parse failure.
|
||||||
|
fn eval_date(field: NaiveDate, op: &Op, value: &str) -> bool {
|
||||||
|
let Ok(v) = NaiveDate::parse_from_str(value, "%Y-%m-%d") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match op {
|
||||||
|
Op::Colon => field == v,
|
||||||
|
Op::Gt => field > v,
|
||||||
|
Op::Lt => field < v,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::tui::state::SessionListItem;
|
||||||
|
|
||||||
|
// ── Helper ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn make_row(
|
||||||
|
model: &str,
|
||||||
|
project: &str,
|
||||||
|
full_id: &str,
|
||||||
|
msg_count: usize,
|
||||||
|
agent_count: usize,
|
||||||
|
date: &str,
|
||||||
|
) -> SessionListItem {
|
||||||
|
SessionListItem {
|
||||||
|
short_id: full_id.get(..8).unwrap_or(full_id).to_string(),
|
||||||
|
full_id: full_id.to_string(),
|
||||||
|
date: date.to_string(),
|
||||||
|
project: project.to_string(),
|
||||||
|
model: model.to_string(),
|
||||||
|
msg_count,
|
||||||
|
agent_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tokenizer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokenize_single_atom() {
|
||||||
|
let tokens = tokenize("model:haiku");
|
||||||
|
assert_eq!(tokens, vec![Token::Atom("model:haiku".to_string())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokenize_and_or_keywords() {
|
||||||
|
let tokens = tokenize("model:haiku AND agents>0 OR date<2026-01-01");
|
||||||
|
assert_eq!(
|
||||||
|
tokens,
|
||||||
|
vec![
|
||||||
|
Token::Atom("model:haiku".to_string()),
|
||||||
|
Token::And,
|
||||||
|
Token::Atom("agents>0".to_string()),
|
||||||
|
Token::Or,
|
||||||
|
Token::Atom("date<2026-01-01".to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokenize_bare_and_or() {
|
||||||
|
// AND/OR without surrounding atoms are still tokenized correctly.
|
||||||
|
let tokens = tokenize("AND OR");
|
||||||
|
assert_eq!(tokens, vec![Token::And, Token::Or]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parse_atom ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_atom_colon() {
|
||||||
|
let p = parse_atom("model:haiku").unwrap();
|
||||||
|
assert_eq!(p.key, Key::Model);
|
||||||
|
assert_eq!(p.op, Op::Colon);
|
||||||
|
assert_eq!(p.value, "haiku");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_atom_gt() {
|
||||||
|
let p = parse_atom("agents>0").unwrap();
|
||||||
|
assert_eq!(p.key, Key::Agents);
|
||||||
|
assert_eq!(p.op, Op::Gt);
|
||||||
|
assert_eq!(p.value, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_atom_lt() {
|
||||||
|
let p = parse_atom("messages<100").unwrap();
|
||||||
|
assert_eq!(p.key, Key::Messages);
|
||||||
|
assert_eq!(p.op, Op::Lt);
|
||||||
|
assert_eq!(p.value, "100");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_atom_unknown_key() {
|
||||||
|
let err = parse_atom("foo:bar").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("unknown filter key 'foo'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_atom_missing_key() {
|
||||||
|
let err = parse_atom(":value").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("missing key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_atom_no_operator() {
|
||||||
|
let err = parse_atom("justword").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("expected operator"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_atom_string_key_gt_rejected() {
|
||||||
|
let err = parse_atom("model>foo").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("only supports ':' operator"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter::parse ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_simple_colon() {
|
||||||
|
assert!(Filter::parse("model:haiku").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_and_expression() {
|
||||||
|
assert!(Filter::parse("model:haiku AND agents>0").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_or_expression() {
|
||||||
|
assert!(Filter::parse("model:haiku OR model:sonnet").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_complex_expression() {
|
||||||
|
assert!(Filter::parse("date>2026-01-01 AND date<2026-12-31").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_string_error() {
|
||||||
|
let err = Filter::parse("").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_key_error() {
|
||||||
|
let err = Filter::parse("bogus:value").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("unknown filter key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_bare_word_error() {
|
||||||
|
let err = Filter::parse("justword").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("unexpected token")
|
||||||
|
|| err.to_string().contains("expected operator")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_dangling_and_error() {
|
||||||
|
let err = Filter::parse("model:haiku AND").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("unexpected end"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_leading_or_error() {
|
||||||
|
let err = Filter::parse("OR model:haiku").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("unexpected 'OR'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter::matches — string fields ─────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_model_substring() {
|
||||||
|
let f = Filter::parse("model:haiku").unwrap();
|
||||||
|
let row = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
assert!(f.matches(&row));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_model_substring_case_insensitive() {
|
||||||
|
let f = Filter::parse("model:HAIKU").unwrap();
|
||||||
|
let row = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
assert!(f.matches(&row));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_match_model_substring() {
|
||||||
|
let f = Filter::parse("model:sonnet").unwrap();
|
||||||
|
let row = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
assert!(!f.matches(&row));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_model_wildcard() {
|
||||||
|
let f = Filter::parse("model:*").unwrap();
|
||||||
|
let row_with_model = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
let row_empty_model = make_row("", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
assert!(f.matches(&row_with_model));
|
||||||
|
assert!(!f.matches(&row_empty_model));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_project_substring() {
|
||||||
|
let f = Filter::parse("project:my-org").unwrap();
|
||||||
|
let row = make_row("claude-sonnet-4-6", "/home/pop/my-org/repo", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
assert!(f.matches(&row));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_id_substring() {
|
||||||
|
let f = Filter::parse("id:aaaaaaaa").unwrap();
|
||||||
|
let row = make_row("claude-sonnet-4-6", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
assert!(f.matches(&row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter::matches — numeric fields ────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_agents_gt() {
|
||||||
|
let f = Filter::parse("agents>0").unwrap();
|
||||||
|
let with_agents = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 2, "2026-03-30 10:00:00");
|
||||||
|
let no_agents = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
assert!(f.matches(&with_agents));
|
||||||
|
assert!(!f.matches(&no_agents));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_messages_lt() {
|
||||||
|
let f = Filter::parse("messages<10").unwrap();
|
||||||
|
let few = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
let many = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 20, 0, "2026-03-30 10:00:00");
|
||||||
|
assert!(f.matches(&few));
|
||||||
|
assert!(!f.matches(&many));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_messages_colon_equality() {
|
||||||
|
let f = Filter::parse("messages:5").unwrap();
|
||||||
|
let five = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
let six = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 6, 0, "2026-03-30 10:00:00");
|
||||||
|
assert!(f.matches(&five));
|
||||||
|
assert!(!f.matches(&six));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter::matches — date field ─────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_date_gt() {
|
||||||
|
let f = Filter::parse("date>2026-03-15").unwrap();
|
||||||
|
let after = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-20 10:00:00");
|
||||||
|
let before = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-10 10:00:00");
|
||||||
|
assert!(f.matches(&after));
|
||||||
|
assert!(!f.matches(&before));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_date_lt() {
|
||||||
|
let f = Filter::parse("date<2026-03-20").unwrap();
|
||||||
|
let before = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-15 10:00:00");
|
||||||
|
let after = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-25 10:00:00");
|
||||||
|
assert!(f.matches(&before));
|
||||||
|
assert!(!f.matches(&after));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_date_colon_equality() {
|
||||||
|
let f = Filter::parse("date:2026-03-20").unwrap();
|
||||||
|
let exact = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-20 10:00:00");
|
||||||
|
let other = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-21 10:00:00");
|
||||||
|
assert!(f.matches(&exact));
|
||||||
|
assert!(!f.matches(&other));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_date_range_and() {
|
||||||
|
let f = Filter::parse("date>2026-03-15 AND date<2026-03-20").unwrap();
|
||||||
|
let inside = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-17 10:00:00");
|
||||||
|
let outside = make_row("model", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-22 10:00:00");
|
||||||
|
assert!(f.matches(&inside));
|
||||||
|
assert!(!f.matches(&outside));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter::matches — logical operators ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_and_both_must_match() {
|
||||||
|
let f = Filter::parse("model:haiku AND agents>0").unwrap();
|
||||||
|
let haiku_agents = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 2, "2026-03-30 10:00:00");
|
||||||
|
let haiku_no_agents = make_row("claude-haiku-4-5", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
let sonnet_agents = make_row("claude-sonnet-4-6", "/proj", "cccccccc-0000-0000-0000-000000000000", 5, 2, "2026-03-30 10:00:00");
|
||||||
|
assert!(f.matches(&haiku_agents));
|
||||||
|
assert!(!f.matches(&haiku_no_agents));
|
||||||
|
assert!(!f.matches(&sonnet_agents));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_or_either_matches() {
|
||||||
|
let f = Filter::parse("model:haiku OR model:sonnet").unwrap();
|
||||||
|
let haiku = make_row("claude-haiku-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
let sonnet = make_row("claude-sonnet-4-6", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
let opus = make_row("claude-opus-4-5", "/proj", "cccccccc-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
assert!(f.matches(&haiku));
|
||||||
|
assert!(f.matches(&sonnet));
|
||||||
|
assert!(!f.matches(&opus));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn and_binds_tighter_than_or() {
|
||||||
|
// "A OR B AND C" should parse as "A OR (B AND C)" — AND binds tighter.
|
||||||
|
let f = Filter::parse("model:opus OR model:haiku AND agents>0").unwrap();
|
||||||
|
// matches opus (left of OR) regardless of agents
|
||||||
|
let opus_no_agents = make_row("claude-opus-4-5", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
// haiku without agents — does NOT match right side (haiku AND agents>0)
|
||||||
|
let haiku_no_agents = make_row("claude-haiku-4-5", "/proj", "bbbbbbbb-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
// haiku with agents — matches right side
|
||||||
|
let haiku_agents = make_row("claude-haiku-4-5", "/proj", "cccccccc-0000-0000-0000-000000000000", 5, 3, "2026-03-30 10:00:00");
|
||||||
|
assert!(f.matches(&opus_no_agents));
|
||||||
|
assert!(!f.matches(&haiku_no_agents));
|
||||||
|
assert!(f.matches(&haiku_agents));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tokens not available ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tokens_filter_returns_false_for_session_list_item() {
|
||||||
|
// SessionListItem does not track token count; tokens filter always false.
|
||||||
|
let f = Filter::parse("tokens>100").unwrap();
|
||||||
|
let row = make_row("model", "/proj", "aaaaaaaa-0000-0000-0000-000000000000", 5, 0, "2026-03-30 10:00:00");
|
||||||
|
assert!(!f.matches(&row));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
//! ANSI color helpers for terminal output.
|
||||||
|
//!
|
||||||
|
//! All functions take a `color_enabled` flag; when `false` the string is
|
||||||
|
//! returned unchanged so callers never need to branch on their own.
|
||||||
|
|
||||||
|
/// Wrap `s` in ANSI escape codes when `color_enabled` is true.
|
||||||
|
///
|
||||||
|
/// `open` is the escape sequence to start the color and `RESET` (`\x1b[0m`)
|
||||||
|
/// is appended automatically.
|
||||||
|
fn colorize(s: &str, open: &str, color_enabled: bool) -> String {
|
||||||
|
if color_enabled {
|
||||||
|
format!("{open}{s}\x1b[0m")
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orange — used for `[assistant]` labels.
|
||||||
|
///
|
||||||
|
/// Uses 256-color code 208 (bright orange).
|
||||||
|
pub fn orange(s: &str, color_enabled: bool) -> String {
|
||||||
|
colorize(s, "\x1b[38;5;208m", color_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grey — used for `[user]` labels.
|
||||||
|
///
|
||||||
|
/// Uses 256-color code 245 (mid-grey).
|
||||||
|
pub fn grey(s: &str, color_enabled: bool) -> String {
|
||||||
|
colorize(s, "\x1b[38;5;245m", color_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blue — used for `[tool: X]` labels.
|
||||||
|
///
|
||||||
|
/// Uses 256-color code 33 (dodger blue).
|
||||||
|
pub fn blue(s: &str, color_enabled: bool) -> String {
|
||||||
|
colorize(s, "\x1b[38;5;33m", color_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Green — used for `[tool_result]` labels.
|
||||||
|
///
|
||||||
|
/// Uses 256-color code 34 (green).
|
||||||
|
pub fn green(s: &str, color_enabled: bool) -> String {
|
||||||
|
colorize(s, "\x1b[32m", color_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Red — used for `[tool_result (error)]` labels.
|
||||||
|
///
|
||||||
|
/// Uses standard ANSI red (code 31).
|
||||||
|
pub fn red(s: &str, color_enabled: bool) -> String {
|
||||||
|
colorize(s, "\x1b[31m", color_enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// When color is disabled, the string is returned unchanged.
|
||||||
|
#[test]
|
||||||
|
fn color_disabled_returns_plain_string() {
|
||||||
|
assert_eq!(orange("hello", false), "hello");
|
||||||
|
assert_eq!(grey("hello", false), "hello");
|
||||||
|
assert_eq!(blue("hello", false), "hello");
|
||||||
|
assert_eq!(green("hello", false), "hello");
|
||||||
|
assert_eq!(red("hello", false), "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When color is enabled, output contains the escape code and a reset.
|
||||||
|
#[test]
|
||||||
|
fn color_enabled_wraps_with_escapes() {
|
||||||
|
let out = orange("[assistant]", true);
|
||||||
|
assert!(out.starts_with("\x1b["), "should start with escape");
|
||||||
|
assert!(out.ends_with("\x1b[0m"), "should end with reset");
|
||||||
|
assert!(out.contains("[assistant]"), "should contain original text");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each color function produces distinct output.
|
||||||
|
#[test]
|
||||||
|
fn each_color_function_is_distinct() {
|
||||||
|
let text = "X";
|
||||||
|
let colors = [
|
||||||
|
orange(text, true),
|
||||||
|
grey(text, true),
|
||||||
|
blue(text, true),
|
||||||
|
green(text, true),
|
||||||
|
red(text, true),
|
||||||
|
];
|
||||||
|
// All pairs must differ.
|
||||||
|
for i in 0..colors.len() {
|
||||||
|
for j in (i + 1)..colors.len() {
|
||||||
|
assert_ne!(colors[i], colors[j], "colors[{i}] == colors[{j}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,11 @@
|
|||||||
//! TUI module — terminal user interface for claudbg.
|
//! TUI module — terminal user interface for claudbg.
|
||||||
//!
|
//!
|
||||||
//! This module will grow to include rendering and event-handling logic.
|
//! - [`state`] — application state model (pure data, no I/O)
|
||||||
//! For now it exposes the application state model used by all TUI screens.
|
//! - [`run`] — terminal setup, main event loop, and teardown
|
||||||
|
//! - [`screens`] — per-screen rendering and event-handling logic
|
||||||
|
//! - [`modals`] — overlay dialogs (quit confirmation, help)
|
||||||
|
|
||||||
|
pub mod modals;
|
||||||
|
pub mod run;
|
||||||
|
pub mod screens;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|||||||
@ -0,0 +1,180 @@
|
|||||||
|
//! Keyboard-shortcut help modal.
|
||||||
|
//!
|
||||||
|
//! Renders a static reference card as a centered overlay.
|
||||||
|
//! Any key (including `Esc`) closes the modal.
|
||||||
|
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
|
||||||
|
use ratatui::layout::{Alignment, Rect};
|
||||||
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||||
|
|
||||||
|
use crate::tui::state::AppState;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Geometry helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Dialog dimensions.
|
||||||
|
const DIALOG_WIDTH: u16 = 32;
|
||||||
|
const DIALOG_HEIGHT: u16 = 15;
|
||||||
|
|
||||||
|
/// Compute a centered [`Rect`] of the given size within `area`.
|
||||||
|
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||||
|
let x = area.x + area.width.saturating_sub(width) / 2;
|
||||||
|
let y = area.y + area.height.saturating_sub(height) / 2;
|
||||||
|
Rect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: width.min(area.width),
|
||||||
|
height: height.min(area.height),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static help content
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const HELP_TEXT: &str = "\
|
||||||
|
Navigation\n\
|
||||||
|
\u{2191}/\u{2193} k/j scroll up/dn\n\
|
||||||
|
\u{2190}/\u{2192} h/l scroll lr\n\
|
||||||
|
Tab cycle panes\n\
|
||||||
|
Enter open/select\n\
|
||||||
|
Esc go back\n\
|
||||||
|
\n\
|
||||||
|
Global\n\
|
||||||
|
q/Q quit\n\
|
||||||
|
? this help\n\
|
||||||
|
c toggle color";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Render the help overlay as a centered dialog.
|
||||||
|
pub fn render_help_modal(f: &mut Frame, area: Rect) {
|
||||||
|
let dialog_area = centered_rect(DIALOG_WIDTH, DIALOG_HEIGHT, area);
|
||||||
|
|
||||||
|
// Clear the background behind the dialog.
|
||||||
|
f.render_widget(Clear, dialog_area);
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Keyboard Shortcuts ")
|
||||||
|
.borders(Borders::ALL);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(HELP_TEXT)
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Left);
|
||||||
|
|
||||||
|
f.render_widget(paragraph, dialog_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Handle an event while the help modal is visible.
|
||||||
|
///
|
||||||
|
/// `Esc` closes the modal; all other keys are also consumed so the modal
|
||||||
|
/// intercepts all input while open. Always returns `true`.
|
||||||
|
pub fn handle_help_modal_event(event: Event, state: &mut AppState) -> bool {
|
||||||
|
let Event::Key(key) = event else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.code == KeyCode::Esc {
|
||||||
|
state.show_help = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn press(code: KeyCode) -> Event {
|
||||||
|
Event::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(code: KeyCode) -> Event {
|
||||||
|
Event::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Release,
|
||||||
|
state: KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Esc` closes the help modal.
|
||||||
|
#[test]
|
||||||
|
fn esc_closes_help() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_help = true;
|
||||||
|
let consumed = handle_help_modal_event(press(KeyCode::Esc), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert!(!state.show_help);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Any other key is consumed but leaves the modal open.
|
||||||
|
#[test]
|
||||||
|
fn other_key_consumed_modal_stays_open() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_help = true;
|
||||||
|
let consumed = handle_help_modal_event(press(KeyCode::Char('x')), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert!(state.show_help);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key-release events are consumed (modal intercepts all input).
|
||||||
|
#[test]
|
||||||
|
fn key_release_consumed() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_help = true;
|
||||||
|
let consumed = handle_help_modal_event(release(KeyCode::Esc), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
// Release does NOT close the modal.
|
||||||
|
assert!(state.show_help);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-key events are consumed.
|
||||||
|
#[test]
|
||||||
|
fn non_key_event_consumed() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_help = true;
|
||||||
|
let consumed = handle_help_modal_event(Event::FocusGained, &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `centered_rect` places the dialog in the middle of the area.
|
||||||
|
#[test]
|
||||||
|
fn centered_rect_centers_correctly() {
|
||||||
|
let area = Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 80,
|
||||||
|
height: 24,
|
||||||
|
};
|
||||||
|
let r = centered_rect(32, 14, area);
|
||||||
|
// x = (80 - 32) / 2 = 24
|
||||||
|
assert_eq!(r.x, 24);
|
||||||
|
// y = (24 - 14) / 2 = 5
|
||||||
|
assert_eq!(r.y, 5);
|
||||||
|
assert_eq!(r.width, 32);
|
||||||
|
assert_eq!(r.height, 14);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
//! TUI modal overlays.
|
||||||
|
//!
|
||||||
|
//! - [`quit_dialog`] — "Quit?" confirmation dialog
|
||||||
|
//! - [`help_modal`] — keyboard-shortcut reference card
|
||||||
|
|
||||||
|
pub mod help_modal;
|
||||||
|
pub mod quit_dialog;
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
//! Quit confirmation dialog modal.
|
||||||
|
//!
|
||||||
|
//! Renders a small centered overlay asking the user to confirm quitting.
|
||||||
|
//! Pressing `q`/`Q` confirms; pressing `Esc` dismisses.
|
||||||
|
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
|
||||||
|
use ratatui::layout::{Alignment, Rect};
|
||||||
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||||
|
|
||||||
|
use crate::tui::state::AppState;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Geometry helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Dialog dimensions.
|
||||||
|
const DIALOG_WIDTH: u16 = 26;
|
||||||
|
const DIALOG_HEIGHT: u16 = 5;
|
||||||
|
|
||||||
|
/// Compute a centered [`Rect`] of the given size within `area`.
|
||||||
|
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||||
|
let x = area.x + area.width.saturating_sub(width) / 2;
|
||||||
|
let y = area.y + area.height.saturating_sub(height) / 2;
|
||||||
|
Rect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: width.min(area.width),
|
||||||
|
height: height.min(area.height),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Render the quit confirmation dialog as a centered overlay.
|
||||||
|
pub fn render_quit_dialog(f: &mut Frame, area: Rect) {
|
||||||
|
let dialog_area = centered_rect(DIALOG_WIDTH, DIALOG_HEIGHT, area);
|
||||||
|
|
||||||
|
// Clear the background behind the dialog.
|
||||||
|
f.render_widget(Clear, dialog_area);
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Quit? ")
|
||||||
|
.borders(Borders::ALL);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(" q = yes Esc = no ")
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
|
||||||
|
f.render_widget(paragraph, dialog_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Handle an event while the quit dialog is visible.
|
||||||
|
///
|
||||||
|
/// All keys are consumed (the dialog intercepts input). Returns `true`
|
||||||
|
/// unconditionally so the caller knows the event was handled.
|
||||||
|
pub fn handle_quit_dialog_event(event: Event, state: &mut AppState) -> bool {
|
||||||
|
let Event::Key(key) = event else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Char('Q') => {
|
||||||
|
state.should_quit = true;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
state.show_quit_dialog = false;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn press(code: KeyCode) -> Event {
|
||||||
|
Event::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(code: KeyCode) -> Event {
|
||||||
|
Event::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Release,
|
||||||
|
state: KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `q` confirms quit.
|
||||||
|
#[test]
|
||||||
|
fn q_confirms_quit() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
let consumed = handle_quit_dialog_event(press(KeyCode::Char('q')), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert!(state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Q` also confirms quit.
|
||||||
|
#[test]
|
||||||
|
fn big_q_confirms_quit() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
handle_quit_dialog_event(press(KeyCode::Char('Q')), &mut state);
|
||||||
|
assert!(state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Esc` dismisses the dialog without quitting.
|
||||||
|
#[test]
|
||||||
|
fn esc_dismisses_dialog() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
let consumed = handle_quit_dialog_event(press(KeyCode::Esc), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert!(!state.show_quit_dialog);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Any other key is consumed but does nothing else.
|
||||||
|
#[test]
|
||||||
|
fn other_key_consumed() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
let consumed = handle_quit_dialog_event(press(KeyCode::Char('x')), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert!(state.show_quit_dialog);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key-release events are consumed (dialog intercepts all input).
|
||||||
|
#[test]
|
||||||
|
fn key_release_consumed() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
let consumed = handle_quit_dialog_event(release(KeyCode::Char('q')), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
// Release should NOT trigger quit.
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-key events are consumed.
|
||||||
|
#[test]
|
||||||
|
fn non_key_event_consumed() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
let consumed = handle_quit_dialog_event(Event::FocusGained, &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `centered_rect` places the dialog in the middle of the area.
|
||||||
|
#[test]
|
||||||
|
fn centered_rect_centers_correctly() {
|
||||||
|
let area = Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 80,
|
||||||
|
height: 24,
|
||||||
|
};
|
||||||
|
let r = centered_rect(26, 5, area);
|
||||||
|
// x = (80 - 26) / 2 = 27
|
||||||
|
assert_eq!(r.x, 27);
|
||||||
|
// y = (24 - 5) / 2 = 9
|
||||||
|
assert_eq!(r.y, 9);
|
||||||
|
assert_eq!(r.width, 26);
|
||||||
|
assert_eq!(r.height, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,318 @@
|
|||||||
|
//! TUI entry point: terminal setup, event loop, and graceful teardown.
|
||||||
|
//!
|
||||||
|
//! The public surface is [`run_tui`], a synchronous function that:
|
||||||
|
//!
|
||||||
|
//! 1. Enables raw mode and switches to the alternate screen.
|
||||||
|
//! 2. Installs a panic hook that restores the terminal before printing the
|
||||||
|
//! panic message, so the user's shell is never left in raw mode.
|
||||||
|
//! 3. Runs the main draw / event loop until the user quits.
|
||||||
|
//! 4. Tears down the terminal (disable raw mode, leave alternate screen) via
|
||||||
|
//! an RAII guard, so cleanup happens even if an error is returned.
|
||||||
|
|
||||||
|
use std::io::{self, Stdout};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||||
|
use ratatui::crossterm::execute;
|
||||||
|
use ratatui::crossterm::terminal::{
|
||||||
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||||
|
};
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::parser::discovery::{discover_agents_for_session, discover_sessions};
|
||||||
|
use crate::tui::modals::help_modal::{handle_help_modal_event, render_help_modal};
|
||||||
|
use crate::tui::modals::quit_dialog::{handle_quit_dialog_event, render_quit_dialog};
|
||||||
|
use crate::tui::screens::session_list::{handle_session_list_event, render_session_list};
|
||||||
|
use crate::tui::screens::transcript::{
|
||||||
|
handle_transcript_event, load_transcript_for_agent, load_transcript_for_session,
|
||||||
|
render_transcript,
|
||||||
|
};
|
||||||
|
use crate::tui::state::{AppState, Screen, SessionListItem};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RAII terminal guard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Restores the terminal to its original state when dropped.
|
||||||
|
///
|
||||||
|
/// Wraps the `Terminal` so that `disable_raw_mode` and
|
||||||
|
/// `LeaveAlternateScreen` are always called, whether the event loop exits
|
||||||
|
/// normally or returns an error.
|
||||||
|
struct TerminalGuard {
|
||||||
|
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalGuard {
|
||||||
|
/// Set up raw mode + alternate screen and build the `Terminal`.
|
||||||
|
fn new() -> Result<Self> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(io::stdout());
|
||||||
|
let terminal = Terminal::new(backend)?;
|
||||||
|
Ok(Self { terminal })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TerminalGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Best-effort: ignore errors during cleanup so we never panic in Drop.
|
||||||
|
let _ = disable_raw_mode();
|
||||||
|
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Panic hook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Install a panic hook that restores the terminal before printing the panic.
|
||||||
|
///
|
||||||
|
/// Without this, a panic in raw mode leaves the user's shell in an unusable
|
||||||
|
/// state. The hook disables raw mode and leaves the alternate screen, then
|
||||||
|
/// delegates to the default panic handler.
|
||||||
|
fn install_panic_hook() {
|
||||||
|
let default_hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
// Ignore errors — we are already panicking.
|
||||||
|
let _ = disable_raw_mode();
|
||||||
|
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||||
|
default_hook(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Draw a single frame, dispatching to the appropriate screen renderer.
|
||||||
|
///
|
||||||
|
/// Modals are rendered on top of the base screen when active.
|
||||||
|
fn render(frame: &mut ratatui::Frame, state: &AppState) {
|
||||||
|
let area = frame.area();
|
||||||
|
match &state.screen {
|
||||||
|
Screen::SessionList => render_session_list(frame, area, state),
|
||||||
|
Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => {
|
||||||
|
render_transcript(frame, area, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay modals on top of the active screen.
|
||||||
|
if state.show_quit_dialog {
|
||||||
|
render_quit_dialog(frame, area);
|
||||||
|
} else if state.show_help {
|
||||||
|
render_help_modal(frame, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Handle a single crossterm [`Event`], mutating `state` as needed.
|
||||||
|
///
|
||||||
|
/// Modal dialogs are checked first and intercept all input while open.
|
||||||
|
/// If no modal is active, dispatches to the appropriate screen handler,
|
||||||
|
/// then falls back to global keybindings for any event not consumed by
|
||||||
|
/// the screen.
|
||||||
|
fn handle_event(event: Event, state: &mut AppState) {
|
||||||
|
// Modal dialogs intercept all input while visible.
|
||||||
|
if state.show_quit_dialog {
|
||||||
|
handle_quit_dialog_event(event, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if state.show_help {
|
||||||
|
handle_help_modal_event(event, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let consumed = match &state.screen {
|
||||||
|
Screen::SessionList => handle_session_list_event(event.clone(), state),
|
||||||
|
Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => {
|
||||||
|
handle_transcript_event(event.clone(), state)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if consumed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global fallback for screens that don't handle quit/back themselves.
|
||||||
|
if let Event::Key(key) = event {
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') => state.should_quit = true,
|
||||||
|
KeyCode::Char('c') => state.color_enabled = !state.color_enabled,
|
||||||
|
KeyCode::Esc => state.go_back(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transcript data loading
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Load transcript entries into `state` if we are on a transcript screen and
|
||||||
|
/// the entries have not been loaded yet.
|
||||||
|
///
|
||||||
|
/// Called once per event loop iteration, after event handling. Because
|
||||||
|
/// discovery and file I/O can be slow, this is a no-op when entries are
|
||||||
|
/// already populated.
|
||||||
|
fn maybe_load_transcript(state: &mut AppState) {
|
||||||
|
if !state.transcript_entries.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match state.screen.clone() {
|
||||||
|
Screen::Transcript { session_id } => {
|
||||||
|
load_transcript_for_session(&session_id, state);
|
||||||
|
}
|
||||||
|
Screen::SubagentTranscript {
|
||||||
|
parent_session_id,
|
||||||
|
agent_id,
|
||||||
|
} => {
|
||||||
|
load_transcript_for_agent(&parent_session_id, &agent_id, state);
|
||||||
|
}
|
||||||
|
Screen::SessionList => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main event loop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Run the TUI synchronously.
|
||||||
|
///
|
||||||
|
/// Initialises the terminal, enters the event loop, and tears down cleanly
|
||||||
|
/// whether the loop exits via `should_quit` or via an error.
|
||||||
|
pub fn run_tui() -> Result<()> {
|
||||||
|
install_panic_hook();
|
||||||
|
|
||||||
|
let mut guard = TerminalGuard::new()?;
|
||||||
|
let mut state = AppState::new();
|
||||||
|
|
||||||
|
// Populate the session list from disk before entering the event loop.
|
||||||
|
// Silently use an empty vec if discovery fails so the TUI still starts.
|
||||||
|
let mut session_refs = discover_sessions().unwrap_or_default();
|
||||||
|
// Sort most-recent-first before converting to display items.
|
||||||
|
session_refs.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||||
|
state.sessions = session_refs
|
||||||
|
.into_iter()
|
||||||
|
.map(|sr| {
|
||||||
|
let short_id = sr.session_id.chars().take(8).collect();
|
||||||
|
let full_id = sr.session_id.clone();
|
||||||
|
let date = sr.modified_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
|
let project = sr.project_path.clone().unwrap_or_default();
|
||||||
|
let agent_count = discover_agents_for_session(&sr.file_path)
|
||||||
|
.map(|v| v.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
SessionListItem {
|
||||||
|
short_id,
|
||||||
|
full_id,
|
||||||
|
date,
|
||||||
|
project,
|
||||||
|
model: String::new(),
|
||||||
|
msg_count: 0,
|
||||||
|
agent_count,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Load transcript data lazily when entering a transcript screen.
|
||||||
|
maybe_load_transcript(&mut state);
|
||||||
|
|
||||||
|
guard.terminal.draw(|f| render(f, &state))?;
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(50))? {
|
||||||
|
handle_event(event::read()?, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.should_quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
// `guard` is dropped here → terminal restored automatically.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::tui::state::Screen;
|
||||||
|
|
||||||
|
fn key_press(code: KeyCode) -> Event {
|
||||||
|
Event::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pressing `q` on the session-list screen shows the quit dialog.
|
||||||
|
#[test]
|
||||||
|
fn q_on_session_list_shows_quit_dialog() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
handle_event(key_press(KeyCode::Char('q')), &mut state);
|
||||||
|
assert!(state.show_quit_dialog);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pressing `q` on the transcript screen shows the quit dialog (same as session list).
|
||||||
|
#[test]
|
||||||
|
fn q_on_transcript_shows_quit_dialog() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.enter_transcript("some-session");
|
||||||
|
handle_event(key_press(KeyCode::Char('q')), &mut state);
|
||||||
|
assert!(state.show_quit_dialog);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pressing `Esc` on the transcript screen calls `go_back` (returns to SessionList).
|
||||||
|
#[test]
|
||||||
|
fn esc_goes_back() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.enter_transcript("some-session");
|
||||||
|
handle_event(key_press(KeyCode::Esc), &mut state);
|
||||||
|
assert_eq!(state.screen, Screen::SessionList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key-release events are ignored.
|
||||||
|
#[test]
|
||||||
|
fn key_release_ignored() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let release_event = Event::Key(KeyEvent {
|
||||||
|
code: KeyCode::Char('q'),
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Release,
|
||||||
|
state: KeyEventState::NONE,
|
||||||
|
});
|
||||||
|
handle_event(release_event, &mut state);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pressing `c` toggles `color_enabled` off then back on.
|
||||||
|
#[test]
|
||||||
|
fn c_toggles_color_enabled() {
|
||||||
|
// SAFETY: single-threaded test; no other threads reading the env.
|
||||||
|
unsafe { std::env::remove_var("NO_COLOR") };
|
||||||
|
let mut state = AppState::new();
|
||||||
|
assert!(state.color_enabled, "color starts enabled");
|
||||||
|
handle_event(key_press(KeyCode::Char('c')), &mut state);
|
||||||
|
assert!(!state.color_enabled, "color toggled off");
|
||||||
|
handle_event(key_press(KeyCode::Char('c')), &mut state);
|
||||||
|
assert!(state.color_enabled, "color toggled back on");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
//! TUI screen modules.
|
||||||
|
//!
|
||||||
|
//! Each sub-module owns the rendering and event-handling logic for one screen.
|
||||||
|
|
||||||
|
pub mod session_list;
|
||||||
|
pub mod transcript;
|
||||||
@ -0,0 +1,697 @@
|
|||||||
|
//! Session-list screen: the TUI home screen.
|
||||||
|
//!
|
||||||
|
//! Renders a full-screen table of sessions and handles navigation within it.
|
||||||
|
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span, Text};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table, TableState};
|
||||||
|
|
||||||
|
use crate::filter::Filter;
|
||||||
|
use crate::tui::state::{AppState, Focus};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Project path truncation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Truncate `path` so that it fits in `max_chars`, prefixing with `…` if
|
||||||
|
/// truncated. Truncates from the *left* (keeps the tail of the path, which
|
||||||
|
/// is most informative).
|
||||||
|
fn truncate_project(path: &str, max_chars: usize) -> String {
|
||||||
|
// Count chars, not bytes.
|
||||||
|
let char_count = path.chars().count();
|
||||||
|
if char_count <= max_chars {
|
||||||
|
path.to_string()
|
||||||
|
} else {
|
||||||
|
// Keep the last (max_chars - 1) chars and prepend the ellipsis.
|
||||||
|
let keep: String = path.chars().rev().take(max_chars - 1).collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect();
|
||||||
|
format!("…{keep}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Draw the session-list screen onto `area` of the given frame.
|
||||||
|
pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
// Split the screen: top portion for the sessions table, bottom 3 rows for the
|
||||||
|
// filter bar (border top + content + border bottom).
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(0), Constraint::Length(3)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let table_area = chunks[0];
|
||||||
|
let filter_area = chunks[1];
|
||||||
|
|
||||||
|
// ── Sessions table ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Column constraints:
|
||||||
|
// ID (8) | Date (20) | Project (dynamic) | Model (20) | Msgs (6) | Agents (7)
|
||||||
|
// Fixed columns total = 8 + 1 + 20 + 1 + 20 + 1 + 6 + 1 + 7 = 65 chars + borders
|
||||||
|
let widths = [
|
||||||
|
Constraint::Length(8),
|
||||||
|
Constraint::Length(20),
|
||||||
|
Constraint::Min(10), // project — gets remaining space
|
||||||
|
Constraint::Length(20),
|
||||||
|
Constraint::Length(6),
|
||||||
|
Constraint::Length(7),
|
||||||
|
];
|
||||||
|
|
||||||
|
let header = Row::new(vec!["ID", "Date", "Project", "Model", "Msgs", "Agents"])
|
||||||
|
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED));
|
||||||
|
|
||||||
|
// Compute the max width available for the project column so we can truncate.
|
||||||
|
// Fixed widths: 8+20+20+6+7 = 61, plus 5 separators = 66, plus 2 borders = 68.
|
||||||
|
// Use 30 chars as a safe default; the Min constraint will expand it.
|
||||||
|
let project_max: usize = table_area
|
||||||
|
.width
|
||||||
|
.saturating_sub(68) // subtract fixed columns + separators + borders
|
||||||
|
.max(10) as usize;
|
||||||
|
let project_display_max = project_max + 30; // generous — actual render clips
|
||||||
|
|
||||||
|
// Apply active filter to the session rows.
|
||||||
|
let active_filter = Filter::parse(&state.filter_active).ok();
|
||||||
|
let filtered_sessions: Vec<_> = state
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
active_filter
|
||||||
|
.as_ref()
|
||||||
|
.map(|f| f.matches(*s))
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rows: Vec<Row> = filtered_sessions
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let project = truncate_project(&s.project, project_display_max);
|
||||||
|
Row::new(vec![
|
||||||
|
s.short_id.clone(),
|
||||||
|
s.date.clone(),
|
||||||
|
project,
|
||||||
|
s.model.clone(),
|
||||||
|
s.msg_count.to_string(),
|
||||||
|
s.agent_count.to_string(),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let table_title = if state.filter_active.is_empty() {
|
||||||
|
" Sessions ".to_string()
|
||||||
|
} else {
|
||||||
|
format!(" Sessions [filter: {}] ", state.filter_active)
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(table_title)
|
||||||
|
.borders(Borders::ALL);
|
||||||
|
|
||||||
|
let highlight_style = Style::default().add_modifier(Modifier::REVERSED);
|
||||||
|
|
||||||
|
let table = Table::new(rows, widths)
|
||||||
|
.header(header)
|
||||||
|
.block(block)
|
||||||
|
.row_highlight_style(highlight_style)
|
||||||
|
.highlight_symbol("► ");
|
||||||
|
|
||||||
|
// Sync ratatui's TableState with our AppState selection.
|
||||||
|
let mut table_state = TableState::default();
|
||||||
|
if !filtered_sessions.is_empty() {
|
||||||
|
let clamped = state.list_selected.min(filtered_sessions.len() - 1);
|
||||||
|
table_state.select(Some(clamped));
|
||||||
|
}
|
||||||
|
|
||||||
|
f.render_stateful_widget(table, table_area, &mut table_state);
|
||||||
|
|
||||||
|
// Render an "empty" hint when there are no sessions.
|
||||||
|
if filtered_sessions.is_empty() {
|
||||||
|
let hint = Paragraph::new(Text::raw(if state.filter_active.is_empty() {
|
||||||
|
"No sessions found."
|
||||||
|
} else {
|
||||||
|
"No sessions match the current filter."
|
||||||
|
}))
|
||||||
|
.alignment(ratatui::layout::Alignment::Center);
|
||||||
|
// Place the hint in the inner area (inside the block border).
|
||||||
|
let inner = Rect {
|
||||||
|
x: table_area.x + 1,
|
||||||
|
y: table_area.y + table_area.height / 2,
|
||||||
|
width: table_area.width.saturating_sub(2),
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
f.render_widget(hint, inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter bar ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
render_filter_bar(f, filter_area, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the filter input bar at the bottom of the screen.
|
||||||
|
fn render_filter_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
||||||
|
let focused = state.focus == Focus::FilterInput;
|
||||||
|
|
||||||
|
let border_style = if focused {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Filter ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(border_style);
|
||||||
|
|
||||||
|
// Build the content line: label + input text (+ cursor when focused).
|
||||||
|
let label = Span::styled("Filter: ", Style::default().add_modifier(Modifier::BOLD));
|
||||||
|
let input_text = if focused {
|
||||||
|
// Show a block cursor at end of input.
|
||||||
|
let mut spans = vec![label, Span::raw(state.filter_input.clone())];
|
||||||
|
spans.push(Span::styled("█", Style::default().fg(Color::Yellow)));
|
||||||
|
Line::from(spans)
|
||||||
|
} else if state.filter_input.is_empty() && state.filter_active.is_empty() {
|
||||||
|
Line::from(vec![
|
||||||
|
label,
|
||||||
|
Span::styled(
|
||||||
|
"Press 't' or Tab to focus — type a query and press Enter",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
Line::from(vec![label, Span::raw(state.filter_input.clone())])
|
||||||
|
};
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(input_text).block(block);
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Return the sessions that match the current active filter.
|
||||||
|
///
|
||||||
|
/// If no filter is active, all sessions are returned.
|
||||||
|
fn filtered_session_indices(state: &AppState) -> Vec<usize> {
|
||||||
|
let active_filter = Filter::parse(&state.filter_active).ok();
|
||||||
|
state
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, s)| {
|
||||||
|
active_filter
|
||||||
|
.as_ref()
|
||||||
|
.map(|f| f.matches(*s))
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a crossterm [`Event`] for the session-list screen.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the event was consumed (the caller should not process it
|
||||||
|
/// further), `false` if it was not handled by this screen.
|
||||||
|
pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
|
||||||
|
let Event::Key(key) = event else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the filter input is focused, handle filter-specific keys first.
|
||||||
|
if state.focus == Focus::FilterInput {
|
||||||
|
return handle_filter_input_event(key.code, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
// Navigate up.
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
state.list_selected = state.list_selected.saturating_sub(1);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Navigate down.
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
let visible = filtered_session_indices(state);
|
||||||
|
if !visible.is_empty() {
|
||||||
|
state.list_selected =
|
||||||
|
(state.list_selected + 1).min(visible.len() - 1);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Enter session transcript.
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let visible = filtered_session_indices(state);
|
||||||
|
let clamped = state.list_selected.min(visible.len().saturating_sub(1));
|
||||||
|
if let Some(&real_idx) = visible.get(clamped) {
|
||||||
|
let full_id = state.sessions[real_idx].full_id.clone();
|
||||||
|
state.enter_transcript(full_id);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Focus the filter input directly.
|
||||||
|
KeyCode::Char('t') => {
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Tab cycles focus: list → filter → list.
|
||||||
|
KeyCode::Tab => {
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Quit — show confirmation dialog rather than exiting immediately.
|
||||||
|
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
|
||||||
|
state.show_quit_dialog = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Help overlay.
|
||||||
|
KeyCode::Char('?') => {
|
||||||
|
state.show_help = true;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a key event while the filter input bar is focused.
|
||||||
|
///
|
||||||
|
/// Returns `true` when the event is consumed.
|
||||||
|
fn handle_filter_input_event(code: KeyCode, state: &mut AppState) -> bool {
|
||||||
|
match code {
|
||||||
|
// Character input: append to filter_input and reset history browsing.
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
state.filter_input.push(c);
|
||||||
|
state.filter_history_pos = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Backspace: remove last char.
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
state.filter_input.pop();
|
||||||
|
state.filter_history_pos = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Enter: apply the current input as the active filter and return focus to the list.
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let query = state.filter_input.trim().to_string();
|
||||||
|
// Record in history (skip duplicate consecutive entries).
|
||||||
|
if !query.is_empty() {
|
||||||
|
let is_dup = state.filter_history.last().map(|l| l == &query).unwrap_or(false);
|
||||||
|
if !is_dup {
|
||||||
|
state.filter_history.push(query.clone());
|
||||||
|
AppState::append_history_to_disk(&query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.filter_active = query;
|
||||||
|
state.filter_history_pos = None;
|
||||||
|
// Reset list selection when filter changes.
|
||||||
|
state.list_selected = 0;
|
||||||
|
// Return focus to the main list.
|
||||||
|
state.focus = Focus::ChatLog;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Escape: clear the text input (but keep the panel visible).
|
||||||
|
KeyCode::Esc => {
|
||||||
|
state.filter_input.clear();
|
||||||
|
state.filter_history_pos = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Up arrow: browse back through history.
|
||||||
|
KeyCode::Up => {
|
||||||
|
if state.filter_history.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let new_pos = match state.filter_history_pos {
|
||||||
|
None => state.filter_history.len() - 1,
|
||||||
|
Some(p) => p.saturating_sub(1),
|
||||||
|
};
|
||||||
|
state.filter_history_pos = Some(new_pos);
|
||||||
|
state.filter_input = state.filter_history[new_pos].clone();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Down arrow: browse forward through history (or clear when past the end).
|
||||||
|
KeyCode::Down => {
|
||||||
|
let Some(pos) = state.filter_history_pos else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if pos + 1 < state.filter_history.len() {
|
||||||
|
let new_pos = pos + 1;
|
||||||
|
state.filter_history_pos = Some(new_pos);
|
||||||
|
state.filter_input = state.filter_history[new_pos].clone();
|
||||||
|
} else {
|
||||||
|
// Past the end: clear input and stop browsing history.
|
||||||
|
state.filter_history_pos = None;
|
||||||
|
state.filter_input.clear();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Tab: cycle focus back to the list.
|
||||||
|
KeyCode::Tab => {
|
||||||
|
state.focus = Focus::ChatLog;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => true, // consume all other keys while filter is focused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::tui::state::{Screen, SessionListItem};
|
||||||
|
|
||||||
|
fn make_item(short_id: &str, full_id: &str) -> SessionListItem {
|
||||||
|
SessionListItem {
|
||||||
|
short_id: short_id.to_string(),
|
||||||
|
full_id: full_id.to_string(),
|
||||||
|
date: "2026-03-29 14:32:01".to_string(),
|
||||||
|
project: "/home/user/project".to_string(),
|
||||||
|
model: "claude-sonnet-4-6".to_string(),
|
||||||
|
msg_count: 10,
|
||||||
|
agent_count: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(code: KeyCode) -> Event {
|
||||||
|
Event::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release(code: KeyCode) -> Event {
|
||||||
|
Event::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Release,
|
||||||
|
state: KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── truncate_project ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_short_path_unchanged() {
|
||||||
|
assert_eq!(truncate_project("/home/user", 30), "/home/user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_exact_length_unchanged() {
|
||||||
|
let path = "a".repeat(30);
|
||||||
|
assert_eq!(truncate_project(&path, 30), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_long_path_prefixed_with_ellipsis() {
|
||||||
|
let path = "/very/long/path/that/exceeds/the/limit/foo/bar";
|
||||||
|
let result = truncate_project(path, 20);
|
||||||
|
assert!(result.starts_with('…'));
|
||||||
|
assert_eq!(result.chars().count(), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── handle_session_list_event ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn down_increments_selection() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
|
||||||
|
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
|
||||||
|
state.list_selected = 0;
|
||||||
|
let consumed = handle_session_list_event(press(KeyCode::Down), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert_eq!(state.list_selected, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn down_clamps_at_end() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
|
||||||
|
state.list_selected = 0;
|
||||||
|
handle_session_list_event(press(KeyCode::Down), &mut state);
|
||||||
|
// Should remain at 0 (only one item).
|
||||||
|
assert_eq!(state.list_selected, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_decrements_selection() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
|
||||||
|
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
|
||||||
|
state.list_selected = 1;
|
||||||
|
let consumed = handle_session_list_event(press(KeyCode::Up), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert_eq!(state.list_selected, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_clamps_at_zero() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.list_selected = 0;
|
||||||
|
handle_session_list_event(press(KeyCode::Up), &mut state);
|
||||||
|
assert_eq!(state.list_selected, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn j_increments_selection() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
|
||||||
|
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
|
||||||
|
state.list_selected = 0;
|
||||||
|
handle_session_list_event(press(KeyCode::Char('j')), &mut state);
|
||||||
|
assert_eq!(state.list_selected, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn k_decrements_selection() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
|
||||||
|
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
|
||||||
|
state.list_selected = 1;
|
||||||
|
handle_session_list_event(press(KeyCode::Char('k')), &mut state);
|
||||||
|
assert_eq!(state.list_selected, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_transitions_to_transcript() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let full = "aaaaaaaa-0000-0000-0000-000000000000";
|
||||||
|
state.sessions.push(make_item("aaaaaaaa", full));
|
||||||
|
state.list_selected = 0;
|
||||||
|
let consumed = handle_session_list_event(press(KeyCode::Enter), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert_eq!(
|
||||||
|
state.screen,
|
||||||
|
Screen::Transcript {
|
||||||
|
session_id: full.to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_on_empty_sessions_does_not_crash() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let consumed = handle_session_list_event(press(KeyCode::Enter), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert_eq!(state.screen, Screen::SessionList);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn q_shows_quit_dialog() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let consumed = handle_session_list_event(press(KeyCode::Char('q')), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert!(state.show_quit_dialog);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn big_q_shows_quit_dialog() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
handle_session_list_event(press(KeyCode::Char('Q')), &mut state);
|
||||||
|
assert!(state.show_quit_dialog);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_shows_quit_dialog() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
handle_session_list_event(press(KeyCode::Esc), &mut state);
|
||||||
|
assert!(state.show_quit_dialog);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn question_mark_shows_help() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let consumed = handle_session_list_event(press(KeyCode::Char('?')), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert!(state.show_help);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unhandled_key_returns_false() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let consumed = handle_session_list_event(press(KeyCode::Char('x')), &mut state);
|
||||||
|
assert!(!consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_release_not_consumed() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let consumed = handle_session_list_event(release(KeyCode::Char('q')), &mut state);
|
||||||
|
assert!(!consumed);
|
||||||
|
assert!(!state.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_key_event_not_consumed() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let consumed = handle_session_list_event(Event::FocusGained, &mut state);
|
||||||
|
assert!(!consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter input focus ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn t_key_focuses_filter_input() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
assert_eq!(state.focus, Focus::ChatLog);
|
||||||
|
let consumed = handle_session_list_event(press(KeyCode::Char('t')), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert_eq!(state.focus, Focus::FilterInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_focuses_filter_input() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let consumed = handle_session_list_event(press(KeyCode::Tab), &mut state);
|
||||||
|
assert!(consumed);
|
||||||
|
assert_eq!(state.focus, Focus::FilterInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_from_filter_cycles_back_to_list() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
handle_session_list_event(press(KeyCode::Tab), &mut state);
|
||||||
|
assert_eq!(state.focus, Focus::ChatLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter character input ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn char_input_appends_to_filter_input() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
handle_session_list_event(press(KeyCode::Char('m')), &mut state);
|
||||||
|
handle_session_list_event(press(KeyCode::Char('o')), &mut state);
|
||||||
|
assert_eq!(state.filter_input, "mo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_removes_last_char() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
state.filter_input = "mo".to_string();
|
||||||
|
handle_session_list_event(press(KeyCode::Backspace), &mut state);
|
||||||
|
assert_eq!(state.filter_input, "m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_clears_filter_input_stays_focused() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
state.filter_input = "foo".to_string();
|
||||||
|
handle_session_list_event(press(KeyCode::Esc), &mut state);
|
||||||
|
assert_eq!(state.filter_input, "");
|
||||||
|
assert_eq!(state.focus, Focus::FilterInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_applies_filter_and_returns_focus_to_list() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
state.filter_input = "model:haiku".to_string();
|
||||||
|
handle_session_list_event(press(KeyCode::Enter), &mut state);
|
||||||
|
assert_eq!(state.filter_active, "model:haiku");
|
||||||
|
assert_eq!(state.focus, Focus::ChatLog);
|
||||||
|
// Should be in history now.
|
||||||
|
assert!(state.filter_history.contains(&"model:haiku".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_empty_clears_active_filter() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
state.filter_active = "model:haiku".to_string();
|
||||||
|
state.filter_input = "".to_string();
|
||||||
|
handle_session_list_event(press(KeyCode::Enter), &mut state);
|
||||||
|
assert_eq!(state.filter_active, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_consecutive_entry_not_added_to_history() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
state.filter_input = "model:haiku".to_string();
|
||||||
|
handle_session_list_event(press(KeyCode::Enter), &mut state);
|
||||||
|
let count_before = state.filter_history.len();
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
state.filter_input = "model:haiku".to_string();
|
||||||
|
handle_session_list_event(press(KeyCode::Enter), &mut state);
|
||||||
|
assert_eq!(state.filter_history.len(), count_before);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filter history navigation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn up_cycles_through_history() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
state.filter_history = vec!["first".to_string(), "second".to_string()];
|
||||||
|
handle_session_list_event(press(KeyCode::Up), &mut state);
|
||||||
|
assert_eq!(state.filter_input, "second");
|
||||||
|
assert_eq!(state.filter_history_pos, Some(1));
|
||||||
|
handle_session_list_event(press(KeyCode::Up), &mut state);
|
||||||
|
assert_eq!(state.filter_input, "first");
|
||||||
|
assert_eq!(state.filter_history_pos, Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn down_goes_forward_in_history() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
state.filter_history = vec!["first".to_string(), "second".to_string()];
|
||||||
|
state.filter_history_pos = Some(0);
|
||||||
|
state.filter_input = "first".to_string();
|
||||||
|
handle_session_list_event(press(KeyCode::Down), &mut state);
|
||||||
|
assert_eq!(state.filter_input, "second");
|
||||||
|
assert_eq!(state.filter_history_pos, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn down_past_end_clears_input() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = Focus::FilterInput;
|
||||||
|
state.filter_history = vec!["first".to_string()];
|
||||||
|
state.filter_history_pos = Some(0);
|
||||||
|
state.filter_input = "first".to_string();
|
||||||
|
handle_session_list_event(press(KeyCode::Down), &mut state);
|
||||||
|
assert_eq!(state.filter_input, "");
|
||||||
|
assert_eq!(state.filter_history_pos, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue