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
|
||||
title: Transcription output improvements
|
||||
status: todo
|
||||
status: completed
|
||||
type: epic
|
||||
priority: normal
|
||||
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.
|
||||
|
||||
@ -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
|
||||
title: Session list & dump improvements
|
||||
status: todo
|
||||
status: completed
|
||||
type: epic
|
||||
priority: normal
|
||||
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.
|
||||
|
||||
@ -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.
|
||||
//!
|
||||
//! This module will grow to include rendering and event-handling logic.
|
||||
//! For now it exposes the application state model used by all TUI screens.
|
||||
//! - [`state`] — application state model (pure data, no I/O)
|
||||
//! - [`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;
|
||||
|
||||
@ -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