Compare commits

...

15 Commits

Author SHA1 Message Date
Elijah Voigt 5bc207c455 feat(claudbg-gf58): TUI filter panel with persistent query history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 403762400e feat(claudbg-horp): add --filter flag to sessions/agents list commands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 277e3a8667 feat(claudbg-4bms): filter query parser with AND/OR and key:value syntax
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt e0398fd5bb feat(claudbg-6gx6): TUI c key toggles color globally
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt c4646cf0e2 feat(claudbg-d8ht): color-coded transcript label prefixes in CLI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 8d72b0e971 feat(claudbg-37cj): add --[no-]color global flag and NO_COLOR support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 9c9f61efd1 feat(claudbg-4g3l): add --limit flag to list commands, default 10
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 970ed0895f feat(claudbg-ltt0): sessions with no subcommand defaults to list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 7f864192e8 fix(tui): populate session list from disk on startup
run_tui() now calls discover_sessions() before entering the event loop,
converts each SessionRef to SessionListItem (sorted most-recent-first),
and assigns to state.sessions. Failures silently yield an empty list.

Fixes claudbg-zi1d

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 3dd463b3ae chore(beans): close completed epics
Mark TUI, session list & dump, and transcription output epics as
completed — all child tasks done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt f80185e442 feat(tui): add sub-agents panel to transcript screen
Splits chat area with a 30-char right panel listing sub-agent runs.
Yellow border highlights the focused pane. Tab cycles focus; j/k
navigates agents when panel focused; Enter drills into sub-agent
transcript. Shows "No sub-agents" when list is empty.

Closes claudbg-9c8r

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 0581431dd7 feat(tui): add transcript screen with stats header and chat log
Renders 4-row stats header (session ID, model, tokens, duration, tools)
above a scrollable chat log (Paragraph::scroll). Thinking blocks filtered,
tool results truncated to 200 chars. Handles scroll, Tab focus toggle,
Esc→back, q→quit dialog. Loads data lazily in event loop.

Closes claudbg-rudq

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 523bf4b89c feat(tui): add quit confirmation dialog and help modal
Quit dialog: centered 26×5 Clear+Block overlay triggered by q/Q,
confirms with q, dismisses with Esc. Help modal: centered 32×14 overlay
triggered by ?, lists all keyboard shortcuts, dismisses with Esc.
Both modals intercept all input while open. Wired into run.rs dispatch.

Closes claudbg-1e1c
Closes claudbg-1tlk

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 06b0c7cc57 feat(tui): add session list screen widget
Renders full-screen session list using ratatui Table with REVERSED
selection highlight. Columns: ID, Date, Project (tail-truncated), Model,
Msgs, Agents. Handles Up/k/Down/j nav, Enter→transcript, q/Esc→quit.
Wired into event loop render dispatch.

Closes claudbg-pta8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 1ddf7fb4fe feat(tui): add terminal setup, event loop, and teardown
Implements run_tui() with RAII terminal guard (TerminalGuard), panic hook
for safe cleanup, and 50ms poll event loop. Placeholder renderer draws a
bordered box. Wires into stubs.rs replacing the coming-soon stub.

Closes claudbg-ut9q

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago

@ -1,11 +1,11 @@
--- ---
# claudbg-1e1c # claudbg-1e1c
title: 'TUI: quit confirmation dialog' title: 'TUI: quit confirmation dialog'
status: todo status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-03-30T04:47:46Z created_at: 2026-03-30T04:47:46Z
updated_at: 2026-03-30T04:49:03Z updated_at: 2026-03-30T16:53:43Z
parent: claudbg-i6l2 parent: claudbg-i6l2
blocked_by: blocked_by:
- claudbg-ut9q - claudbg-ut9q
@ -46,3 +46,5 @@ Centered overlay on top of the current screen (using `ratatui::widgets::Clear` t
## Blocked by ## Blocked by
- event loop (claudbg-ut9q) - event loop (claudbg-ut9q)
## Summary of Changes\n\nCreated src/tui/modals/quit_dialog.rs with render_quit_dialog() (26×5 centered overlay) and handle_quit_dialog_event() (q/Q→quit, Esc→dismiss). Updated session_list.rs q handler to set show_quit_dialog=true. Wired into run.rs. 147 tests pass.

@ -1,11 +1,11 @@
--- ---
# claudbg-1tlk # claudbg-1tlk
title: 'TUI: help modal listing all keyboard shortcuts' title: 'TUI: help modal listing all keyboard shortcuts'
status: todo status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-03-30T04:48:38Z created_at: 2026-03-30T04:48:38Z
updated_at: 2026-03-30T04:49:03Z updated_at: 2026-03-30T16:53:43Z
parent: claudbg-i6l2 parent: claudbg-i6l2
blocked_by: blocked_by:
- claudbg-ut9q - claudbg-ut9q
@ -53,3 +53,5 @@ Press `Escape` to close.
## Blocked by ## Blocked by
- event loop (claudbg-ut9q) - event loop (claudbg-ut9q)
## Summary of Changes\n\nCreated src/tui/modals/help_modal.rs with render_help_modal() (32×14 centered Clear+Block overlay, static shortcuts list) and handle_help_modal_event() (Esc dismisses, all keys consumed). Wired into run.rs render and event dispatch.

@ -0,0 +1,10 @@
---
# claudbg-2vwx
title: Filter queries
status: todo
type: epic
created_at: 2026-03-31T00:32:47Z
updated_at: 2026-03-31T00:32:47Z
---
Add filtering support to narrow session/agent list views. Covers the query language parser, CLI --filter flag, and TUI filter panel.

@ -0,0 +1,11 @@
---
# claudbg-37cj
title: Add --[no-]color global flag and NO_COLOR env var support
status: todo
type: task
created_at: 2026-03-31T00:32:57Z
updated_at: 2026-03-31T00:32:57Z
parent: claudbg-qpfe
---
Add `--color` / `--no-color` as a global CLI flag (available on all commands). When not specified, auto-detect: enable color if stdout is a tty and NO_COLOR is unset/empty. Honor the NO_COLOR environment variable (any non-empty value → disable color), per the NO_COLOR spec. This flag controls all ANSI output in the CLI (transcripts etc).

@ -0,0 +1,60 @@
---
# claudbg-4bms
title: Filter query parser
status: completed
type: task
priority: normal
created_at: 2026-03-31T00:33:08Z
updated_at: 2026-03-31T05:04:41Z
parent: claudbg-2vwx
---
Implement the filter query language:
**Syntax:**
- `key:value` — substring match (e.g. `model:haiku` matches `claude-haiku-4-5`)
- `key:*` — field is non-empty
- `key>value` / `key<value` — comparison for numeric and date fields
- `AND` / `OR` for combining expressions
- Multiple `--filter` flags are ANDed together
**Supported keys:**
- `model` — substring match on model name
- `project` — substring match on project path
- `id` — substring match on session/agent ID
- `agents` — count of sub-agent runs (numeric)
- `messages` — total message count (numeric, all roles)
- `tokens` — total token count (numeric)
- `date` — session start date (ISO 8601, e.g. `2026-03-20`)
**Errors:** malformed syntax or unknown key → fail with a user-readable error message.
## Summary of Changes
Implemented the filter query language as a standalone module in `src/filter.rs`.
### What was built
- **`Filter` type** with `Filter::parse(s: &str) -> Result<Filter>` — hand-rolled recursive-descent parser, no new dependencies
- **`SessionRow` trait** with fields: `model`, `project`, `id`, `agents`, `messages`, `tokens`, `date`
- **`impl SessionRow for SessionListItem`** — wires to all `SessionListItem` fields; `tokens()` returns `None` (token count not yet tracked in that struct)
- **`Filter::matches(&self, row: &R) -> bool`** evaluates the parsed AST against any `SessionRow`
- **`pub use filter::Filter`** re-exported from `src/lib.rs`
### Parser details
- Tokenizer splits on whitespace; identifies `AND`/`OR` keywords and key-op-value atoms
- Recursive descent: `or_expr → and_expr ( OR and_expr )*`, `and_expr → primary ( AND primary )*`
- AND binds tighter than OR (standard precedence)
- String keys (`model`, `project`, `id`): `:` only; case-insensitive substring match; `*` matches any non-empty
- Numeric keys (`agents`, `messages`, `tokens`): `:` (equality), `>`, `<`
- Date key (`date`): ISO 8601 `YYYY-MM-DD`; `:` (equality), `>`, `<`
- Unknown keys and malformed syntax produce `AppError::Parse` with user-readable messages
### Not-yet-wired fields
- `tokens``SessionListItem` does not carry a token count; `tokens:`/`tokens>`/`tokens<` always returns `false` until the field is added
### Tests
39 new unit tests in `filter::tests` covering: tokenizer, atom parser, `Filter::parse` (valid + error cases), and `Filter::matches` for all key types and logical operators. All 234 project tests pass.

@ -0,0 +1,10 @@
---
# claudbg-4g3l
title: Limit list output to 10 by default with --limit flag
status: todo
type: feature
created_at: 2026-03-31T00:32:40Z
updated_at: 2026-03-31T00:32:40Z
---
Both `sessions list` and `agents list <session-id>` should default to showing only the 10 most recent entries (sorted most-recent-first). Add a `--limit=<N|all>` flag to override: accepts an integer (e.g. `--limit=50`) or the keyword `all` to show everything.

@ -0,0 +1,21 @@
---
# claudbg-6gx6
title: 'TUI: color toggle with ''c'' key'
status: completed
type: task
priority: normal
created_at: 2026-03-31T00:33:01Z
updated_at: 2026-03-31T04:32:25Z
parent: claudbg-qpfe
---
In the TUI, pressing `c` toggles color coding on/off globally (affects all transcript views). Color is on by default unless NO_COLOR is set. The toggle state persists for the duration of the TUI session.
## Summary of Changes
Added a global color toggle to the TUI, activated with the `c` key:
- **`src/tui/state.rs`**: Added `color_enabled: bool` field to `AppState`. Initialised in `AppState::new()` by checking the `NO_COLOR` env var (false if set and non-empty, true otherwise). Added tests for both cases.
- **`src/tui/run.rs`**: Added `KeyCode::Char('c')` to the global event fallback handler to toggle `state.color_enabled`. Added a test for the toggle.
- **`src/tui/screens/transcript.rs`**: Updated `build_chat_lines` to accept a `color_enabled: bool` parameter and conditionally apply ratatui `Style` colours to role labels (orange for assistant, grey for user), tool-use lines (cyan), tool-result lines (green/red), and image lines (yellow). When `color_enabled` is false, all spans use `Style::default()`. Updated the call site in `render_transcript` to pass `state.color_enabled`. Updated all test calls to pass `false`.
- **`src/tui/modals/help_modal.rs`**: Added `c toggle color` line to `HELP_TEXT` and bumped `DIALOG_HEIGHT` from 14 to 15 to accommodate it.

@ -1,10 +1,11 @@
--- ---
# claudbg-8vpb # claudbg-8vpb
title: Transcription output improvements title: Transcription output improvements
status: todo status: completed
type: epic type: epic
priority: normal
created_at: 2026-03-30T04:43:30Z created_at: 2026-03-30T04:43:30Z
updated_at: 2026-03-30T04:43:30Z updated_at: 2026-03-30T17:02:41Z
--- ---
Groups bugs in `sessions transcribe` and `agents transcribe` output: tools display format, duration miscalculation, tool result visibility defaults, and tool input truncation. Groups bugs in `sessions transcribe` and `agents transcribe` output: tools display format, duration miscalculation, tool result visibility defaults, and tool input truncation.

@ -1,11 +1,11 @@
--- ---
# claudbg-9c8r # claudbg-9c8r
title: 'TUI: sub-agents panel in transcript view' title: 'TUI: sub-agents panel in transcript view'
status: todo status: completed
type: feature type: feature
priority: normal priority: normal
created_at: 2026-03-30T04:47:15Z created_at: 2026-03-30T04:47:15Z
updated_at: 2026-03-30T04:49:03Z updated_at: 2026-03-30T17:02:14Z
parent: claudbg-i6l2 parent: claudbg-i6l2
blocked_by: blocked_by:
- claudbg-rudq - claudbg-rudq
@ -42,3 +42,5 @@ The panel is ~25% of the frame width (or a fixed ~30 chars).
- transcript screen (claudbg-rudq) - transcript screen (claudbg-rudq)
- agent discovery fix (claudbg-33n0) - agent discovery fix (claudbg-33n0)
## Summary of Changes\n\nModified render_transcript() to split chat area horizontally (Min(0) + Length(30)). Sub-agents panel shows agent list with yellow border/selection highlight when focused. Tab toggles focus. j/k navigates sub-agents when panel focused. Enter navigates to SubagentTranscript. Panel shows 'No sub-agents' when empty. 184 tests pass.

@ -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.

@ -1,10 +1,11 @@
--- ---
# claudbg-i6l2 # claudbg-i6l2
title: 'TUI: interactive session browser' title: 'TUI: interactive session browser'
status: todo status: completed
type: epic type: epic
priority: normal
created_at: 2026-03-30T04:44:52Z created_at: 2026-03-30T04:44:52Z
updated_at: 2026-03-30T04:44:52Z updated_at: 2026-03-30T17:02:35Z
--- ---
Implement a full terminal UI using Ratatui for browsing Claude Code sessions. Implement a full terminal UI using Ratatui for browsing Claude Code sessions.
@ -39,3 +40,5 @@ Sorted most-recent-first.
**Non-goals for this milestone** **Non-goals for this milestone**
- Live refresh / follow mode (future feature) - Live refresh / follow mode (future feature)
- Color themes / configuration - Color themes / configuration
## Summary of Changes\n\nFull TUI implementation completed across 8 sub-tasks:\n- ratatui + crossterm deps added\n- AppState/Screen/Focus model with navigation methods\n- Terminal setup, RAII guard, panic hook, 50ms event loop\n- Session list screen (Table widget, j/k nav)\n- Transcript screen (stats header + scrollable chat log)\n- Sub-agents panel (30-char right split, Tab focus, Enter drill-in)\n- Quit confirmation dialog (q→confirm, Esc→cancel)\n- Help modal (? → shortcuts overlay)

@ -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.

@ -1,11 +1,11 @@
--- ---
# claudbg-nq36 # claudbg-nq36
title: 'TUI: app state model and screen enum' title: 'TUI: app state model and screen enum'
status: in-progress status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-03-30T04:45:19Z created_at: 2026-03-30T04:45:19Z
updated_at: 2026-03-30T16:37:25Z updated_at: 2026-03-30T16:40:31Z
parent: claudbg-i6l2 parent: claudbg-i6l2
blocked_by: blocked_by:
- claudbg-78xt - claudbg-78xt
@ -54,3 +54,5 @@ pub struct AppState {
## Blocked by ## Blocked by
- ratatui dependency task (claudbg-78xt) - ratatui dependency task (claudbg-78xt)
## Summary of Changes\n\nCreated src/tui/mod.rs and src/tui/state.rs with Screen enum, Focus enum, AppState struct, SessionListItem, and AgentRef types. Added state transition methods: new(), enter_transcript(), enter_subagent_transcript(), go_back(). Reuses existing RawEntry and AgentRef types. 11 unit tests all pass. Wired into lib.rs via pub mod tui.

@ -1,11 +1,11 @@
--- ---
# claudbg-pta8 # claudbg-pta8
title: 'TUI: session list screen widget' title: 'TUI: session list screen widget'
status: todo status: completed
type: feature type: feature
priority: normal priority: normal
created_at: 2026-03-30T04:45:53Z created_at: 2026-03-30T04:45:53Z
updated_at: 2026-03-30T04:49:03Z updated_at: 2026-03-30T16:50:08Z
parent: claudbg-i6l2 parent: claudbg-i6l2
blocked_by: blocked_by:
- claudbg-nq36 - claudbg-nq36
@ -38,3 +38,5 @@ Render the full-screen session list as the TUI's home screen.
- app state model (claudbg-nq36) - app state model (claudbg-nq36)
- agent discovery fix (claudbg-33n0) — for accurate agent counts - agent discovery fix (claudbg-33n0) — for accurate agent counts
## Summary of Changes\n\nCreated src/tui/screens/session_list.rs with render_session_list() (ratatui Table with REVERSED highlight, truncated project paths) and handle_session_list_event() (Up/k/Down/j navigation, Enter→transcript, q/Esc→quit, ?→help). 18 unit tests. Wired into run.rs render dispatch and event delegation. 134 total tests pass.

@ -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,11 +1,11 @@
--- ---
# claudbg-rudq # claudbg-rudq
title: 'TUI: transcript screen — chat log and stats header' title: 'TUI: transcript screen — chat log and stats header'
status: todo status: completed
type: feature type: feature
priority: normal priority: normal
created_at: 2026-03-30T04:46:56Z created_at: 2026-03-30T04:46:56Z
updated_at: 2026-03-30T04:49:03Z updated_at: 2026-03-30T16:59:42Z
parent: claudbg-i6l2 parent: claudbg-i6l2
blocked_by: blocked_by:
- claudbg-ut9q - claudbg-ut9q
@ -44,3 +44,5 @@ Load `RawEntry` list from raw JSONL via `read_session_file` when entering transc
## Blocked by ## Blocked by
- app state (claudbg-nq36), event loop (claudbg-ut9q) - app state (claudbg-nq36), event loop (claudbg-ut9q)
## Summary of Changes\n\nCreated src/tui/screens/transcript.rs with render_transcript() (4-row stats header + scrollable chat log via Paragraph::scroll) and handle_transcript_event() (Up/k/Down/j/Left/h/Right/l scroll, Tab focus toggle, Esc→back, q→quit dialog). Data loaded lazily via maybe_load_transcript() in run.rs event loop. Thinking blocks skipped, tool results truncated to 200 chars. Subagents populated for future panel. 37 new tests, 184 total passing.

@ -1,10 +1,11 @@
--- ---
# claudbg-tci9 # claudbg-tci9
title: Session list & dump improvements title: Session list & dump improvements
status: todo status: completed
type: epic type: epic
priority: normal
created_at: 2026-03-30T04:34:16Z created_at: 2026-03-30T04:34:16Z
updated_at: 2026-03-30T04:34:16Z updated_at: 2026-03-30T17:02:41Z
--- ---
Groups bugs and improvements to the `sessions list` and `sessions dump` commands: table overflow, JSON output format, --include help text, duplicate row collapsing, and sub-agent count. Groups bugs and improvements to the `sessions list` and `sessions dump` commands: table overflow, JSON output format, --include help text, duplicate row collapsing, and sub-agent count.

@ -1,11 +1,11 @@
--- ---
# claudbg-ut9q # claudbg-ut9q
title: 'TUI: terminal setup, event loop, and teardown' title: 'TUI: terminal setup, event loop, and teardown'
status: todo status: completed
type: task type: task
priority: normal priority: normal
created_at: 2026-03-30T04:45:32Z created_at: 2026-03-30T04:45:32Z
updated_at: 2026-03-30T04:49:03Z updated_at: 2026-03-30T16:47:13Z
parent: claudbg-i6l2 parent: claudbg-i6l2
blocked_by: blocked_by:
- claudbg-nq36 - claudbg-nq36
@ -36,3 +36,5 @@ Replaces the stub in `src/commands/stubs.rs` (the `Tui` command handler). The `t
## Blocked by ## Blocked by
- ratatui deps (claudbg-78xt), app state (claudbg-nq36) - ratatui deps (claudbg-78xt), app state (claudbg-nq36)
## Summary of Changes\n\nCreated src/tui/run.rs with TerminalGuard (RAII cleanup), install_panic_hook, run_tui() event loop at 50ms poll. Placeholder render draws a bordered box. Handles q/Esc to quit. Added should_quit field to AppState. Wired into stubs.rs replacing the coming-soon stub. cargo check and clippy pass clean.

@ -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

@ -70,6 +70,7 @@
clang clang
beans beans
jq jq
rtk
]; ];
}; };
}); });

@ -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>

@ -1,4 +1,4 @@
//! CLI types: output format, include list, global options, and command structure. //! CLI types: output format, include list, limit, global options, and command structure.
/// Output format for all commands. /// Output format for all commands.
#[derive(Debug, Clone, Default, clap::ValueEnum)] #[derive(Debug, Clone, Default, clap::ValueEnum)]
@ -51,6 +51,46 @@ impl std::str::FromStr for IncludeList {
} }
} }
/// How many entries to show in list commands.
///
/// Accepts an integer (e.g. `10`) or the keyword `all` to show every entry.
#[derive(Debug, Clone)]
pub enum Limit {
/// Show at most N entries.
Count(usize),
/// Show all entries without truncation.
All,
}
impl Default for Limit {
fn default() -> Self {
Limit::Count(10)
}
}
impl std::str::FromStr for Limit {
type Err = crate::error::AppError;
fn from_str(s: &str) -> crate::error::Result<Self> {
if s.eq_ignore_ascii_case("all") {
return Ok(Limit::All);
}
s.parse::<usize>()
.map(Limit::Count)
.map_err(|_| crate::error::AppError::InvalidArg(format!("invalid limit: {s}")))
}
}
impl Limit {
/// Apply this limit to a vector, returning a truncated or full slice.
pub fn apply<T>(&self, items: Vec<T>) -> Vec<T> {
match self {
Limit::All => items,
Limit::Count(n) => items.into_iter().take(*n).collect(),
}
}
}
/// Top-level CLI entry point for claudbg. /// Top-level CLI entry point for claudbg.
#[derive(Debug, clap::Parser)] #[derive(Debug, clap::Parser)]
#[command(name = "claudbg", about = "Claude Code session inspector")] #[command(name = "claudbg", about = "Claude Code session inspector")]
@ -76,16 +116,48 @@ pub struct GlobalOpts {
/// Valid values: thinking, output. Example: --include thinking,output /// Valid values: thinking, output. Example: --include thinking,output
#[arg(long, global = true, default_value = "")] #[arg(long, global = true, default_value = "")]
pub include: IncludeList, pub include: IncludeList,
/// Force color output even when not writing to a terminal.
#[arg(long, global = true, overrides_with = "no_color")]
pub color: bool,
/// Disable color output (also honored via the NO_COLOR env var).
#[arg(long = "no-color", global = true, overrides_with = "color")]
pub no_color: bool,
}
impl GlobalOpts {
/// Determine whether color output should be enabled.
///
/// Priority (highest → lowest):
/// 1. `NO_COLOR` env var set to a non-empty value → disabled.
/// 2. `--no-color` flag passed → disabled.
/// 3. `--color` flag passed → enabled.
/// 4. Auto-detect: enabled iff stdout is a TTY.
pub fn color_enabled(&self) -> bool {
use std::io::IsTerminal as _;
// NO_COLOR spec: any non-empty value disables color.
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
return false;
}
if self.no_color {
return false;
}
if self.color {
return true;
}
std::io::stdout().is_terminal()
}
} }
/// Top-level subcommands. /// Top-level subcommands.
#[derive(Debug, clap::Subcommand)] #[derive(Debug, clap::Subcommand)]
pub enum Commands { pub enum Commands {
/// List, dump, and transcribe sessions. /// List, dump, and transcribe sessions.
#[command(subcommand_required = false)]
Sessions { Sessions {
/// Sessions subcommand. /// Sessions subcommand (defaults to `list` when omitted).
#[command(subcommand)] #[command(subcommand)]
cmd: SessionsCmd, cmd: Option<SessionsCmd>,
}, },
/// List, dump, and transcribe sub-agent runs. /// List, dump, and transcribe sub-agent runs.
Agents { Agents {
@ -109,7 +181,15 @@ pub enum Commands {
#[derive(Debug, clap::Subcommand)] #[derive(Debug, clap::Subcommand)]
pub enum SessionsCmd { pub enum SessionsCmd {
/// List all sessions, most recent first. /// List all sessions, most recent first.
List, List {
/// Maximum number of sessions to show, or "all" for no limit.
#[arg(long, default_value = "10")]
limit: Limit,
/// Filter expression (may be passed multiple times; all filters are ANDed).
/// Example: --filter 'model:haiku' --filter 'agents>0'
#[arg(long, action = clap::ArgAction::Append)]
filter: Vec<String>,
},
/// Dump raw messages from a session. /// Dump raw messages from a session.
Dump { Dump {
/// Session ID or 8-char prefix. /// Session ID or 8-char prefix.
@ -135,6 +215,13 @@ pub enum AgentsCmd {
List { List {
/// Parent session ID or 8-char prefix. /// Parent session ID or 8-char prefix.
session_id: String, session_id: String,
/// Maximum number of agents to show, or "all" for no limit.
#[arg(long, default_value = "10")]
limit: Limit,
/// Filter expression (may be passed multiple times; all filters are ANDed).
/// Example: --filter 'id:abc12345'
#[arg(long, action = clap::ArgAction::Append)]
filter: Vec<String>,
}, },
/// Dump raw messages from an agent run. /// Dump raw messages from an agent run.
Dump { Dump {
@ -218,4 +305,63 @@ mod tests {
let fmt = OutputFormat::default(); let fmt = OutputFormat::default();
assert!(matches!(fmt, OutputFormat::Table)); assert!(matches!(fmt, OutputFormat::Table));
} }
/// Parsing a number produces `Limit::Count`.
#[test]
fn limit_parse_count() {
let limit = Limit::from_str("5").unwrap();
assert!(matches!(limit, Limit::Count(5)));
}
/// Parsing `"all"` produces `Limit::All`.
#[test]
fn limit_parse_all() {
let limit = Limit::from_str("all").unwrap();
assert!(matches!(limit, Limit::All));
}
/// Parsing `"ALL"` (case-insensitive) also produces `Limit::All`.
#[test]
fn limit_parse_all_uppercase() {
let limit = Limit::from_str("ALL").unwrap();
assert!(matches!(limit, Limit::All));
}
/// Parsing an invalid string returns an error.
#[test]
fn limit_parse_invalid() {
let result = Limit::from_str("banana");
assert!(result.is_err());
}
/// Default limit is `Count(10)`.
#[test]
fn limit_default_is_ten() {
let limit = Limit::default();
assert!(matches!(limit, Limit::Count(10)));
}
/// `Limit::Count(3).apply` truncates a longer vec.
#[test]
fn limit_apply_count_truncates() {
let v = vec![1, 2, 3, 4, 5];
let result = Limit::Count(3).apply(v);
assert_eq!(result, vec![1, 2, 3]);
}
/// `Limit::All.apply` returns the full vec unchanged.
#[test]
fn limit_apply_all_returns_all() {
let v = vec![1, 2, 3, 4, 5];
let result = Limit::All.apply(v);
assert_eq!(result, vec![1, 2, 3, 4, 5]);
}
/// `Limit::Count(N).apply` on a shorter vec returns the full vec.
#[test]
fn limit_apply_count_shorter_vec_unchanged() {
let v = vec![1, 2];
let result = Limit::Count(10).apply(v);
assert_eq!(result, vec![1, 2]);
}
} }

@ -45,20 +45,32 @@ fn content_preview(entry: &crate::models::session::RawEntry, max_len: usize) ->
/// ///
/// Thinking blocks are shown only when `opts.include.thinking` is set. /// Thinking blocks are shown only when `opts.include.thinking` is set.
/// Tool result blocks are always shown, truncated to 200 chars unless `opts.verbose`. /// Tool result blocks are always shown, truncated to 200 chars unless `opts.verbose`.
/// Label prefixes are color-coded when `opts.color_enabled()` returns `true`.
fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) { fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) {
let Some(msg) = &entry.message else { return }; let Some(msg) = &entry.message else { return };
let role = msg.role.as_deref().unwrap_or("?"); let role = msg.role.as_deref().unwrap_or("?");
let color = opts.color_enabled();
// Returns a colored role label like "[assistant]" or "[user]".
let role_label = |r: &str| -> String {
let label = format!("[{r}]");
match r {
"assistant" => crate::output::color::orange(&label, color),
"user" => crate::output::color::grey(&label, color),
_ => label,
}
};
match &msg.content { match &msg.content {
None => {} None => {}
Some(crate::models::session::MessageContent::Text(t)) => { Some(crate::models::session::MessageContent::Text(t)) => {
println!("[{role}]: {t}"); println!("{}: {t}", role_label(role));
} }
Some(crate::models::session::MessageContent::Blocks(blocks)) => { Some(crate::models::session::MessageContent::Blocks(blocks)) => {
for block in blocks { for block in blocks {
match block { match block {
crate::models::session::ContentBlock::Text { text } => { crate::models::session::ContentBlock::Text { text } => {
println!("[{role}]: {text}"); println!("{}: {text}", role_label(role));
} }
crate::models::session::ContentBlock::Thinking { thinking } => { crate::models::session::ContentBlock::Thinking { thinking } => {
if opts.include.thinking { if opts.include.thinking {
@ -71,15 +83,19 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
let boundary = input_preview.floor_char_boundary(cap); let boundary = input_preview.floor_char_boundary(cap);
let input_short = &input_preview[..boundary]; let input_short = &input_preview[..boundary];
let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" }; let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" };
println!("[tool: {name}] {input_short}{ellipsis}"); let label = crate::output::color::blue(&format!("[tool: {name}]"), color);
println!("{label} {input_short}{ellipsis}");
} }
crate::models::session::ContentBlock::ToolResult { crate::models::session::ContentBlock::ToolResult {
content, is_error, .. content, is_error, ..
} => { } => {
let err_flag = if is_error.unwrap_or(false) { let is_err = is_error.unwrap_or(false);
" (error)" let err_flag = if is_err { " (error)" } else { "" };
let label_text = format!("[tool_result{err_flag}]");
let label = if is_err {
crate::output::color::red(&label_text, color)
} else { } else {
"" crate::output::color::green(&label_text, color)
}; };
let preview = content let preview = content
.as_ref() .as_ref()
@ -88,11 +104,11 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
content.as_ref().map(|c| c.to_string()).unwrap_or_default() content.as_ref().map(|c| c.to_string()).unwrap_or_default()
}); });
if opts.verbose { if opts.verbose {
println!("[tool_result{err_flag}]: {preview}"); println!("{label}: {preview}");
} else { } else {
let boundary = preview.floor_char_boundary(200); let boundary = preview.floor_char_boundary(200);
let short = &preview[..boundary]; let short = &preview[..boundary];
println!("[tool_result{err_flag}]: {short}"); println!("{label}: {short}");
} }
} }
crate::models::session::ContentBlock::Image { .. } => { crate::models::session::ContentBlock::Image { .. } => {
@ -135,6 +151,40 @@ fn find_agent(session_id: &str, agent_id: &str) -> Result<crate::parser::discove
Ok(agent_ref) Ok(agent_ref)
} }
// ---------------------------------------------------------------------------
// Helper: SessionRow impl for AgentRef
// ---------------------------------------------------------------------------
/// Wrapper around [`crate::parser::discovery::AgentRef`] that implements
/// [`crate::filter::SessionRow`] so filters can be applied to agent list rows.
struct AgentRowRef<'a>(&'a crate::parser::discovery::AgentRef);
impl crate::filter::SessionRow for AgentRowRef<'_> {
fn model(&self) -> &str {
// Expose agent_type via the `model` key so filters like `model:subagent` work.
self.0.agent_type.as_deref().unwrap_or("")
}
fn project(&self) -> &str {
// No meaningful project for an agent row.
""
}
fn id(&self) -> &str {
&self.0.agent_id
}
fn agents(&self) -> u64 {
0
}
fn messages(&self) -> u64 {
0
}
fn tokens(&self) -> Option<u64> {
None
}
fn date(&self) -> Option<chrono::NaiveDate> {
Some(self.0.modified_at.date_naive())
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Public commands // Public commands
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -147,7 +197,21 @@ fn find_agent(session_id: &str, agent_id: &str) -> Result<crate::parser::discove
/// ///
/// Respects `opts.output` and `opts.verbose` (verbose shows full UUIDs and /// Respects `opts.output` and `opts.verbose` (verbose shows full UUIDs and
/// full file paths instead of 8-char prefixes and basenames). /// full file paths instead of 8-char prefixes and basenames).
pub async fn list(session_id: &str, opts: &crate::cli::GlobalOpts) -> Result<()> { /// The `limit` controls how many rows are returned (default 10, or all).
/// The `filters` are ANDed together; each is a filter query string as
/// accepted by [`crate::filter::Filter::parse`].
pub async fn list(
session_id: &str,
limit: crate::cli::Limit,
filters: Vec<String>,
opts: &crate::cli::GlobalOpts,
) -> Result<()> {
// Parse all filter expressions up front so we can report errors immediately.
let parsed_filters: Vec<crate::filter::Filter> = filters
.iter()
.map(|s| crate::filter::Filter::parse(s))
.collect::<Result<Vec<_>>>()?;
let sessions = crate::parser::discovery::discover_sessions()?; let sessions = crate::parser::discovery::discover_sessions()?;
let session_ref = sessions let session_ref = sessions
.iter() .iter()
@ -158,6 +222,12 @@ pub async fn list(session_id: &str, opts: &crate::cli::GlobalOpts) -> Result<()>
let agents = crate::parser::discovery::discover_agents_for_session(&session_ref.file_path)?; let agents = crate::parser::discovery::discover_agents_for_session(&session_ref.file_path)?;
// Apply filters (AND semantics: all filters must match).
let agents: Vec<_> = agents
.into_iter()
.filter(|a| parsed_filters.iter().all(|f| f.matches(&AgentRowRef(a))))
.collect();
let rows: Vec<Vec<String>> = agents let rows: Vec<Vec<String>> = agents
.iter() .iter()
.map(|a| { .map(|a| {
@ -185,6 +255,8 @@ pub async fn list(session_id: &str, opts: &crate::cli::GlobalOpts) -> Result<()>
}) })
.collect(); .collect();
let rows = limit.apply(rows);
let output = match opts.output { let output = match opts.output {
crate::cli::OutputFormat::Table => { crate::cli::OutputFormat::Table => {
crate::output::render_table(&["Agent ID", "Type", "File", "Modified"], &rows)? crate::output::render_table(&["Agent ID", "Type", "File", "Modified"], &rows)?
@ -461,6 +533,8 @@ mod tests {
output: OutputFormat::Table, output: OutputFormat::Table,
verbose: false, verbose: false,
include: IncludeList::default(), include: IncludeList::default(),
color: false,
no_color: false,
} }
} }
@ -491,7 +565,7 @@ mod tests {
// SAFETY: single-threaded test context; no other threads read HOME concurrently. // SAFETY: single-threaded test context; no other threads read HOME concurrently.
unsafe { std::env::set_var("HOME", dir.path()) }; unsafe { std::env::set_var("HOME", dir.path()) };
let opts = default_opts(); let opts = default_opts();
let result = list("nonexistent-session-id-xyz", &opts).await; let result = list("nonexistent-session-id-xyz", crate::cli::Limit::default(), vec![], &opts).await;
assert!( assert!(
matches!(result, Err(crate::error::AppError::NotFound(_))), matches!(result, Err(crate::error::AppError::NotFound(_))),
"expected NotFound, got: {result:?}" "expected NotFound, got: {result:?}"
@ -547,7 +621,7 @@ mod tests {
// SAFETY: single-threaded test context; no other threads read HOME concurrently. // SAFETY: single-threaded test context; no other threads read HOME concurrently.
unsafe { std::env::set_var("HOME", dir.path()) }; unsafe { std::env::set_var("HOME", dir.path()) };
let opts = default_opts(); let opts = default_opts();
let result = list("aaaabbbb", &opts).await; let result = list("aaaabbbb", crate::cli::Limit::default(), vec![], &opts).await;
assert!(result.is_ok(), "expected Ok, got: {result:?}"); assert!(result.is_ok(), "expected Ok, got: {result:?}");
} }

@ -84,20 +84,32 @@ fn content_preview(entry: &crate::models::session::RawEntry, max_len: usize) ->
/// ///
/// Thinking blocks are shown only when `opts.include.thinking` is set. /// Thinking blocks are shown only when `opts.include.thinking` is set.
/// Tool result blocks are always shown, truncated to 200 chars unless `opts.verbose`. /// Tool result blocks are always shown, truncated to 200 chars unless `opts.verbose`.
/// Label prefixes are color-coded when `opts.color_enabled()` returns `true`.
fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) { fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) {
let Some(msg) = &entry.message else { return }; let Some(msg) = &entry.message else { return };
let role = msg.role.as_deref().unwrap_or("?"); let role = msg.role.as_deref().unwrap_or("?");
let color = opts.color_enabled();
// Returns a colored role label like "[assistant]" or "[user]".
let role_label = |r: &str| -> String {
let label = format!("[{r}]");
match r {
"assistant" => crate::output::color::orange(&label, color),
"user" => crate::output::color::grey(&label, color),
_ => label,
}
};
match &msg.content { match &msg.content {
None => {} None => {}
Some(crate::models::session::MessageContent::Text(t)) => { Some(crate::models::session::MessageContent::Text(t)) => {
println!("[{role}]: {t}"); println!("{}: {t}", role_label(role));
} }
Some(crate::models::session::MessageContent::Blocks(blocks)) => { Some(crate::models::session::MessageContent::Blocks(blocks)) => {
for block in blocks { for block in blocks {
match block { match block {
crate::models::session::ContentBlock::Text { text } => { crate::models::session::ContentBlock::Text { text } => {
println!("[{role}]: {text}"); println!("{}: {text}", role_label(role));
} }
crate::models::session::ContentBlock::Thinking { thinking } => { crate::models::session::ContentBlock::Thinking { thinking } => {
if opts.include.thinking { if opts.include.thinking {
@ -110,15 +122,19 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
let boundary = input_preview.floor_char_boundary(cap); let boundary = input_preview.floor_char_boundary(cap);
let input_short = &input_preview[..boundary]; let input_short = &input_preview[..boundary];
let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" }; let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" };
println!("[tool: {name}] {input_short}{ellipsis}"); let label = crate::output::color::blue(&format!("[tool: {name}]"), color);
println!("{label} {input_short}{ellipsis}");
} }
crate::models::session::ContentBlock::ToolResult { crate::models::session::ContentBlock::ToolResult {
content, is_error, .. content, is_error, ..
} => { } => {
let err_flag = if is_error.unwrap_or(false) { let is_err = is_error.unwrap_or(false);
" (error)" let err_flag = if is_err { " (error)" } else { "" };
let label_text = format!("[tool_result{err_flag}]");
let label = if is_err {
crate::output::color::red(&label_text, color)
} else { } else {
"" crate::output::color::green(&label_text, color)
}; };
let preview = content let preview = content
.as_ref() .as_ref()
@ -127,11 +143,11 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
content.as_ref().map(|c| c.to_string()).unwrap_or_default() content.as_ref().map(|c| c.to_string()).unwrap_or_default()
}); });
if opts.verbose { if opts.verbose {
println!("[tool_result{err_flag}]: {preview}"); println!("{label}: {preview}");
} else { } else {
let boundary = preview.floor_char_boundary(200); let boundary = preview.floor_char_boundary(200);
let short = &preview[..boundary]; let short = &preview[..boundary];
println!("[tool_result{err_flag}]: {short}"); println!("{label}: {short}");
} }
} }
crate::models::session::ContentBlock::Image { .. } => { crate::models::session::ContentBlock::Image { .. } => {
@ -146,12 +162,70 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
} }
} }
// ---------------------------------------------------------------------------
// Helper struct for filter evaluation
// ---------------------------------------------------------------------------
/// Raw session data used to evaluate filter predicates before building display strings.
struct RawSessionRow {
session_id: String,
project_path: String,
model: String,
last_msg_at: String,
message_count: i64,
agent_count: usize,
}
impl crate::filter::SessionRow for RawSessionRow {
fn model(&self) -> &str {
&self.model
}
fn project(&self) -> &str {
&self.project_path
}
fn id(&self) -> &str {
&self.session_id
}
fn agents(&self) -> u64 {
self.agent_count as u64
}
fn messages(&self) -> u64 {
self.message_count as u64
}
fn tokens(&self) -> Option<u64> {
None
}
fn date(&self) -> Option<chrono::NaiveDate> {
// last_msg_at is e.g. "2026-03-30 14:22:01"; parse the date portion.
self.last_msg_at
.get(..10)
.and_then(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
}
}
// ---------------------------------------------------------------------------
// sessions list
// ---------------------------------------------------------------------------
/// Run `sessions list`. /// Run `sessions list`.
/// ///
/// Discovers all session files, syncs them to the DB, then queries the DB /// Discovers all session files, syncs them to the DB, then queries the DB
/// for a summary sorted most-recent-first. Respects `opts.output` and /// for a summary sorted most-recent-first. Respects `opts.output` and
/// `opts.verbose` (verbose shows full UUIDs instead of 8-char prefixes). /// `opts.verbose` (verbose shows full UUIDs instead of 8-char prefixes).
pub async fn list(opts: &crate::cli::GlobalOpts) -> Result<()> { /// The `limit` controls how many rows are returned (default 10, or all).
/// The `filters` are ANDed together; each is a filter query string as
/// accepted by [`crate::filter::Filter::parse`].
pub async fn list(
limit: crate::cli::Limit,
filters: Vec<String>,
opts: &crate::cli::GlobalOpts,
) -> Result<()> {
// Parse all filter expressions up front so we can report errors immediately.
let parsed_filters: Vec<crate::filter::Filter> = filters
.iter()
.map(|s| crate::filter::Filter::parse(s))
.collect::<Result<Vec<_>>>()?;
let db_path = crate::db::connection::default_db_path(); let db_path = crate::db::connection::default_db_path();
let db = crate::db::connection::open_db(&db_path, false).await?; let db = crate::db::connection::open_db(&db_path, false).await?;
@ -180,8 +254,8 @@ pub async fn list(opts: &crate::cli::GlobalOpts) -> Result<()> {
.await .await
.map_err(|e| crate::error::AppError::Db(e.to_string()))?; .map_err(|e| crate::error::AppError::Db(e.to_string()))?;
// Collect rows. // Collect raw rows.
let mut rows: Vec<Vec<String>> = Vec::new(); let mut raw_rows: Vec<RawSessionRow> = Vec::new();
while let Some(row) = rows_cursor while let Some(row) = rows_cursor
.next() .next()
.await .await
@ -193,44 +267,66 @@ pub async fn list(opts: &crate::cli::GlobalOpts) -> Result<()> {
let last_msg_at: String = row.get(3).unwrap_or_default(); let last_msg_at: String = row.get(3).unwrap_or_default();
let message_count: i64 = row.get(4).unwrap_or_default(); let message_count: i64 = row.get(4).unwrap_or_default();
// Count sub-agents for this session.
let agent_count = if let Some(file_path) = session_file_map.get(&session_id) {
crate::parser::discovery::discover_agents_for_session(file_path)
.unwrap_or_default()
.len()
} else {
0
};
raw_rows.push(RawSessionRow {
session_id,
project_path,
model,
last_msg_at,
message_count,
agent_count,
});
}
// Apply filters (AND semantics: all filters must match).
let raw_rows: Vec<RawSessionRow> = raw_rows
.into_iter()
.filter(|r| parsed_filters.iter().all(|f| f.matches(r)))
.collect();
// Convert to display rows.
let mut rows: Vec<Vec<String>> = Vec::new();
for raw in raw_rows {
let display_id = if opts.verbose { let display_id = if opts.verbose {
session_id.clone() raw.session_id.clone()
} else { } else {
crate::util::short_id(&session_id).to_string() crate::util::short_id(&raw.session_id).to_string()
}; };
const MAX_PATH_LEN: usize = 40; const MAX_PATH_LEN: usize = 40;
let display_path = if project_path.len() > MAX_PATH_LEN { let display_path = if raw.project_path.len() > MAX_PATH_LEN {
let boundary = project_path let boundary = raw
.project_path
.char_indices() .char_indices()
.rev() .rev()
.map(|(i, _)| i) .map(|(i, _)| i)
.nth(MAX_PATH_LEN - 1) .nth(MAX_PATH_LEN - 1)
.unwrap_or(0); .unwrap_or(0);
format!("…{}", &project_path[boundary..]) format!("…{}", &raw.project_path[boundary..])
} else { } else {
project_path raw.project_path
};
// Count sub-agents for this session.
let agent_count = if let Some(file_path) = session_file_map.get(&session_id) {
crate::parser::discovery::discover_agents_for_session(file_path)
.unwrap_or_default()
.len()
} else {
0
}; };
rows.push(vec![ rows.push(vec![
display_id, display_id,
last_msg_at, raw.last_msg_at,
display_path, display_path,
model, raw.model,
message_count.to_string(), raw.message_count.to_string(),
agent_count.to_string(), raw.agent_count.to_string(),
]); ]);
} }
let rows = limit.apply(rows);
let output = match opts.output { let output = match opts.output {
crate::cli::OutputFormat::Table => { crate::cli::OutputFormat::Table => {
crate::output::render_table( crate::output::render_table(
@ -644,6 +740,8 @@ mod tests {
output: OutputFormat::Table, output: OutputFormat::Table,
verbose: false, verbose: false,
include: IncludeList::default(), include: IncludeList::default(),
color: false,
no_color: false,
} }
} }
@ -676,7 +774,7 @@ mod tests {
// SAFETY: single-threaded test context; no other threads read HOME concurrently. // SAFETY: single-threaded test context; no other threads read HOME concurrently.
unsafe { std::env::set_var("HOME", dir.path()) }; unsafe { std::env::set_var("HOME", dir.path()) };
let opts = default_opts(); let opts = default_opts();
let result = list(&opts).await; let result = list(crate::cli::Limit::default(), vec![], &opts).await;
assert!(result.is_ok(), "list failed: {:?}", result.err()); assert!(result.is_ok(), "list failed: {:?}", result.err());
} }
@ -692,7 +790,7 @@ mod tests {
output: OutputFormat::Json, output: OutputFormat::Json,
..default_opts() ..default_opts()
}; };
let result = list(&opts).await; let result = list(crate::cli::Limit::default(), vec![], &opts).await;
assert!(result.is_ok(), "list json failed: {:?}", result.err()); assert!(result.is_ok(), "list json failed: {:?}", result.err());
} }
@ -708,7 +806,7 @@ mod tests {
output: OutputFormat::Xml, output: OutputFormat::Xml,
..default_opts() ..default_opts()
}; };
let result = list(&opts).await; let result = list(crate::cli::Limit::default(), vec![], &opts).await;
assert!(result.is_ok(), "list xml failed: {:?}", result.err()); assert!(result.is_ok(), "list xml failed: {:?}", result.err());
} }

@ -1,13 +1,14 @@
//! Stub implementations for commands not yet fully implemented. //! Stub implementations for commands not yet fully implemented.
use crate::error::Result; use crate::error::Result;
use crate::tui::run::run_tui;
/// Run the `tui` subcommand stub. /// Run the `tui` subcommand.
/// ///
/// Prints a placeholder message until the TUI is implemented. /// Initialises the terminal, runs the interactive event loop, and restores
/// the terminal on exit.
pub fn tui() -> Result<()> { pub fn tui() -> Result<()> {
println!("tui: coming soon!"); run_tui()
Ok(())
} }
/// Run the `query` subcommand stub. /// Run the `query` subcommand stub.
@ -24,13 +25,6 @@ pub fn query() -> Result<()> {
mod tests { mod tests {
use super::*; use super::*;
/// `tui()` returns `Ok` without panicking.
#[test]
fn tui_returns_ok() {
let result = tui();
assert!(result.is_ok());
}
/// `query()` returns `Ok` without panicking. /// `query()` returns `Ok` without panicking.
#[test] #[test]
fn query_returns_ok() { fn query_returns_ok() {

@ -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));
}
}

@ -4,12 +4,15 @@ pub mod cli;
pub mod commands; pub mod commands;
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod filter;
pub mod models; pub mod models;
pub mod output; pub mod output;
pub mod parser; pub mod parser;
pub mod tui; pub mod tui;
pub mod util; pub mod util;
pub use filter::Filter;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// Verify the crate compiles and the lib entry point is reachable. /// Verify the crate compiles and the lib entry point is reachable.

@ -8,8 +8,8 @@ use claudbg::error::Result;
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Commands::Sessions { cmd } => match cmd { Commands::Sessions { cmd } => match cmd.unwrap_or(SessionsCmd::List { limit: Default::default(), filter: vec![] }) {
SessionsCmd::List => claudbg::commands::sessions::list(&cli.global).await?, SessionsCmd::List { limit, filter } => claudbg::commands::sessions::list(limit, filter, &cli.global).await?,
SessionsCmd::Dump { id, follow } => { SessionsCmd::Dump { id, follow } => {
claudbg::commands::sessions::dump(&id, follow, &cli.global).await? claudbg::commands::sessions::dump(&id, follow, &cli.global).await?
} }
@ -18,8 +18,8 @@ async fn main() -> Result<()> {
} }
}, },
Commands::Agents { cmd } => match cmd { Commands::Agents { cmd } => match cmd {
AgentsCmd::List { session_id } => { AgentsCmd::List { session_id, limit, filter } => {
claudbg::commands::agents::list(&session_id, &cli.global).await? claudbg::commands::agents::list(&session_id, limit, filter, &cli.global).await?
} }
AgentsCmd::Dump { AgentsCmd::Dump {
session_id, session_id,

@ -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,5 +1,6 @@
//! Output format renderers: table, JSON, XML. //! Output format renderers: table, JSON, XML.
pub mod color;
pub mod json; pub mod json;
pub mod table; pub mod table;
pub mod xml; pub mod xml;

@ -1,6 +1,11 @@
//! TUI module — terminal user interface for claudbg. //! TUI module — terminal user interface for claudbg.
//! //!
//! This module will grow to include rendering and event-handling logic. //! - [`state`] — application state model (pure data, no I/O)
//! For now it exposes the application state model used by all TUI screens. //! - [`run`] — terminal setup, main event loop, and teardown
//! - [`screens`] — per-screen rendering and event-handling logic
//! - [`modals`] — overlay dialogs (quit confirmation, help)
pub mod modals;
pub mod run;
pub mod screens;
pub mod state; pub mod state;

@ -0,0 +1,180 @@
//! Keyboard-shortcut help modal.
//!
//! Renders a static reference card as a centered overlay.
//! Any key (including `Esc`) closes the modal.
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::layout::{Alignment, Rect};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::tui::state::AppState;
// ---------------------------------------------------------------------------
// Geometry helpers
// ---------------------------------------------------------------------------
/// Dialog dimensions.
const DIALOG_WIDTH: u16 = 32;
const DIALOG_HEIGHT: u16 = 15;
/// Compute a centered [`Rect`] of the given size within `area`.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect {
x,
y,
width: width.min(area.width),
height: height.min(area.height),
}
}
// ---------------------------------------------------------------------------
// Static help content
// ---------------------------------------------------------------------------
const HELP_TEXT: &str = "\
Navigation\n\
\u{2191}/\u{2193} k/j scroll up/dn\n\
\u{2190}/\u{2192} h/l scroll lr\n\
Tab cycle panes\n\
Enter open/select\n\
Esc go back\n\
\n\
Global\n\
q/Q quit\n\
? this help\n\
c toggle color";
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Render the help overlay as a centered dialog.
pub fn render_help_modal(f: &mut Frame, area: Rect) {
let dialog_area = centered_rect(DIALOG_WIDTH, DIALOG_HEIGHT, area);
// Clear the background behind the dialog.
f.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" Keyboard Shortcuts ")
.borders(Borders::ALL);
let paragraph = Paragraph::new(HELP_TEXT)
.block(block)
.alignment(Alignment::Left);
f.render_widget(paragraph, dialog_area);
}
// ---------------------------------------------------------------------------
// Event handling
// ---------------------------------------------------------------------------
/// Handle an event while the help modal is visible.
///
/// `Esc` closes the modal; all other keys are also consumed so the modal
/// intercepts all input while open. Always returns `true`.
pub fn handle_help_modal_event(event: Event, state: &mut AppState) -> bool {
let Event::Key(key) = event else {
return true;
};
if key.kind != KeyEventKind::Press {
return true;
}
if key.code == KeyCode::Esc {
state.show_help = false;
}
true
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
use super::*;
fn press(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
fn release(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
})
}
/// `Esc` closes the help modal.
#[test]
fn esc_closes_help() {
let mut state = AppState::new();
state.show_help = true;
let consumed = handle_help_modal_event(press(KeyCode::Esc), &mut state);
assert!(consumed);
assert!(!state.show_help);
}
/// Any other key is consumed but leaves the modal open.
#[test]
fn other_key_consumed_modal_stays_open() {
let mut state = AppState::new();
state.show_help = true;
let consumed = handle_help_modal_event(press(KeyCode::Char('x')), &mut state);
assert!(consumed);
assert!(state.show_help);
}
/// Key-release events are consumed (modal intercepts all input).
#[test]
fn key_release_consumed() {
let mut state = AppState::new();
state.show_help = true;
let consumed = handle_help_modal_event(release(KeyCode::Esc), &mut state);
assert!(consumed);
// Release does NOT close the modal.
assert!(state.show_help);
}
/// Non-key events are consumed.
#[test]
fn non_key_event_consumed() {
let mut state = AppState::new();
state.show_help = true;
let consumed = handle_help_modal_event(Event::FocusGained, &mut state);
assert!(consumed);
}
/// `centered_rect` places the dialog in the middle of the area.
#[test]
fn centered_rect_centers_correctly() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let r = centered_rect(32, 14, area);
// x = (80 - 32) / 2 = 24
assert_eq!(r.x, 24);
// y = (24 - 14) / 2 = 5
assert_eq!(r.y, 5);
assert_eq!(r.width, 32);
assert_eq!(r.height, 14);
}
}

@ -0,0 +1,7 @@
//! TUI modal overlays.
//!
//! - [`quit_dialog`] — "Quit?" confirmation dialog
//! - [`help_modal`] — keyboard-shortcut reference card
pub mod help_modal;
pub mod quit_dialog;

@ -0,0 +1,190 @@
//! Quit confirmation dialog modal.
//!
//! Renders a small centered overlay asking the user to confirm quitting.
//! Pressing `q`/`Q` confirms; pressing `Esc` dismisses.
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::layout::{Alignment, Rect};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::tui::state::AppState;
// ---------------------------------------------------------------------------
// Geometry helpers
// ---------------------------------------------------------------------------
/// Dialog dimensions.
const DIALOG_WIDTH: u16 = 26;
const DIALOG_HEIGHT: u16 = 5;
/// Compute a centered [`Rect`] of the given size within `area`.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect {
x,
y,
width: width.min(area.width),
height: height.min(area.height),
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Render the quit confirmation dialog as a centered overlay.
pub fn render_quit_dialog(f: &mut Frame, area: Rect) {
let dialog_area = centered_rect(DIALOG_WIDTH, DIALOG_HEIGHT, area);
// Clear the background behind the dialog.
f.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" Quit? ")
.borders(Borders::ALL);
let paragraph = Paragraph::new(" q = yes Esc = no ")
.block(block)
.alignment(Alignment::Center);
f.render_widget(paragraph, dialog_area);
}
// ---------------------------------------------------------------------------
// Event handling
// ---------------------------------------------------------------------------
/// Handle an event while the quit dialog is visible.
///
/// All keys are consumed (the dialog intercepts input). Returns `true`
/// unconditionally so the caller knows the event was handled.
pub fn handle_quit_dialog_event(event: Event, state: &mut AppState) -> bool {
let Event::Key(key) = event else {
return true;
};
if key.kind != KeyEventKind::Press {
return true;
}
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => {
state.should_quit = true;
}
KeyCode::Esc => {
state.show_quit_dialog = false;
}
_ => {}
}
true
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
use super::*;
fn press(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
fn release(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
})
}
/// `q` confirms quit.
#[test]
fn q_confirms_quit() {
let mut state = AppState::new();
state.show_quit_dialog = true;
let consumed = handle_quit_dialog_event(press(KeyCode::Char('q')), &mut state);
assert!(consumed);
assert!(state.should_quit);
}
/// `Q` also confirms quit.
#[test]
fn big_q_confirms_quit() {
let mut state = AppState::new();
state.show_quit_dialog = true;
handle_quit_dialog_event(press(KeyCode::Char('Q')), &mut state);
assert!(state.should_quit);
}
/// `Esc` dismisses the dialog without quitting.
#[test]
fn esc_dismisses_dialog() {
let mut state = AppState::new();
state.show_quit_dialog = true;
let consumed = handle_quit_dialog_event(press(KeyCode::Esc), &mut state);
assert!(consumed);
assert!(!state.show_quit_dialog);
assert!(!state.should_quit);
}
/// Any other key is consumed but does nothing else.
#[test]
fn other_key_consumed() {
let mut state = AppState::new();
state.show_quit_dialog = true;
let consumed = handle_quit_dialog_event(press(KeyCode::Char('x')), &mut state);
assert!(consumed);
assert!(state.show_quit_dialog);
assert!(!state.should_quit);
}
/// Key-release events are consumed (dialog intercepts all input).
#[test]
fn key_release_consumed() {
let mut state = AppState::new();
state.show_quit_dialog = true;
let consumed = handle_quit_dialog_event(release(KeyCode::Char('q')), &mut state);
assert!(consumed);
// Release should NOT trigger quit.
assert!(!state.should_quit);
}
/// Non-key events are consumed.
#[test]
fn non_key_event_consumed() {
let mut state = AppState::new();
state.show_quit_dialog = true;
let consumed = handle_quit_dialog_event(Event::FocusGained, &mut state);
assert!(consumed);
}
/// `centered_rect` places the dialog in the middle of the area.
#[test]
fn centered_rect_centers_correctly() {
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let r = centered_rect(26, 5, area);
// x = (80 - 26) / 2 = 27
assert_eq!(r.x, 27);
// y = (24 - 5) / 2 = 9
assert_eq!(r.y, 9);
assert_eq!(r.width, 26);
assert_eq!(r.height, 5);
}
}

@ -0,0 +1,318 @@
//! TUI entry point: terminal setup, event loop, and graceful teardown.
//!
//! The public surface is [`run_tui`], a synchronous function that:
//!
//! 1. Enables raw mode and switches to the alternate screen.
//! 2. Installs a panic hook that restores the terminal before printing the
//! panic message, so the user's shell is never left in raw mode.
//! 3. Runs the main draw / event loop until the user quits.
//! 4. Tears down the terminal (disable raw mode, leave alternate screen) via
//! an RAII guard, so cleanup happens even if an error is returned.
use std::io::{self, Stdout};
use std::time::Duration;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use crate::error::Result;
use crate::parser::discovery::{discover_agents_for_session, discover_sessions};
use crate::tui::modals::help_modal::{handle_help_modal_event, render_help_modal};
use crate::tui::modals::quit_dialog::{handle_quit_dialog_event, render_quit_dialog};
use crate::tui::screens::session_list::{handle_session_list_event, render_session_list};
use crate::tui::screens::transcript::{
handle_transcript_event, load_transcript_for_agent, load_transcript_for_session,
render_transcript,
};
use crate::tui::state::{AppState, Screen, SessionListItem};
// ---------------------------------------------------------------------------
// RAII terminal guard
// ---------------------------------------------------------------------------
/// Restores the terminal to its original state when dropped.
///
/// Wraps the `Terminal` so that `disable_raw_mode` and
/// `LeaveAlternateScreen` are always called, whether the event loop exits
/// normally or returns an error.
struct TerminalGuard {
terminal: Terminal<CrosstermBackend<Stdout>>,
}
impl TerminalGuard {
/// Set up raw mode + alternate screen and build the `Terminal`.
fn new() -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
// Best-effort: ignore errors during cleanup so we never panic in Drop.
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
}
}
// ---------------------------------------------------------------------------
// Panic hook
// ---------------------------------------------------------------------------
/// Install a panic hook that restores the terminal before printing the panic.
///
/// Without this, a panic in raw mode leaves the user's shell in an unusable
/// state. The hook disables raw mode and leaves the alternate screen, then
/// delegates to the default panic handler.
fn install_panic_hook() {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// Ignore errors — we are already panicking.
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
default_hook(info);
}));
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Draw a single frame, dispatching to the appropriate screen renderer.
///
/// Modals are rendered on top of the base screen when active.
fn render(frame: &mut ratatui::Frame, state: &AppState) {
let area = frame.area();
match &state.screen {
Screen::SessionList => render_session_list(frame, area, state),
Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => {
render_transcript(frame, area, state);
}
}
// Overlay modals on top of the active screen.
if state.show_quit_dialog {
render_quit_dialog(frame, area);
} else if state.show_help {
render_help_modal(frame, area);
}
}
// ---------------------------------------------------------------------------
// Event handling
// ---------------------------------------------------------------------------
/// Handle a single crossterm [`Event`], mutating `state` as needed.
///
/// Modal dialogs are checked first and intercept all input while open.
/// If no modal is active, dispatches to the appropriate screen handler,
/// then falls back to global keybindings for any event not consumed by
/// the screen.
fn handle_event(event: Event, state: &mut AppState) {
// Modal dialogs intercept all input while visible.
if state.show_quit_dialog {
handle_quit_dialog_event(event, state);
return;
}
if state.show_help {
handle_help_modal_event(event, state);
return;
}
let consumed = match &state.screen {
Screen::SessionList => handle_session_list_event(event.clone(), state),
Screen::Transcript { .. } | Screen::SubagentTranscript { .. } => {
handle_transcript_event(event.clone(), state)
}
};
if consumed {
return;
}
// Global fallback for screens that don't handle quit/back themselves.
if let Event::Key(key) = event {
if key.kind != KeyEventKind::Press {
return;
}
match key.code {
KeyCode::Char('q') => state.should_quit = true,
KeyCode::Char('c') => state.color_enabled = !state.color_enabled,
KeyCode::Esc => state.go_back(),
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Transcript data loading
// ---------------------------------------------------------------------------
/// Load transcript entries into `state` if we are on a transcript screen and
/// the entries have not been loaded yet.
///
/// Called once per event loop iteration, after event handling. Because
/// discovery and file I/O can be slow, this is a no-op when entries are
/// already populated.
fn maybe_load_transcript(state: &mut AppState) {
if !state.transcript_entries.is_empty() {
return;
}
match state.screen.clone() {
Screen::Transcript { session_id } => {
load_transcript_for_session(&session_id, state);
}
Screen::SubagentTranscript {
parent_session_id,
agent_id,
} => {
load_transcript_for_agent(&parent_session_id, &agent_id, state);
}
Screen::SessionList => {}
}
}
// ---------------------------------------------------------------------------
// Main event loop
// ---------------------------------------------------------------------------
/// Run the TUI synchronously.
///
/// Initialises the terminal, enters the event loop, and tears down cleanly
/// whether the loop exits via `should_quit` or via an error.
pub fn run_tui() -> Result<()> {
install_panic_hook();
let mut guard = TerminalGuard::new()?;
let mut state = AppState::new();
// Populate the session list from disk before entering the event loop.
// Silently use an empty vec if discovery fails so the TUI still starts.
let mut session_refs = discover_sessions().unwrap_or_default();
// Sort most-recent-first before converting to display items.
session_refs.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
state.sessions = session_refs
.into_iter()
.map(|sr| {
let short_id = sr.session_id.chars().take(8).collect();
let full_id = sr.session_id.clone();
let date = sr.modified_at.format("%Y-%m-%d %H:%M:%S").to_string();
let project = sr.project_path.clone().unwrap_or_default();
let agent_count = discover_agents_for_session(&sr.file_path)
.map(|v| v.len())
.unwrap_or(0);
SessionListItem {
short_id,
full_id,
date,
project,
model: String::new(),
msg_count: 0,
agent_count,
}
})
.collect();
loop {
// Load transcript data lazily when entering a transcript screen.
maybe_load_transcript(&mut state);
guard.terminal.draw(|f| render(f, &state))?;
if event::poll(Duration::from_millis(50))? {
handle_event(event::read()?, &mut state);
}
if state.should_quit {
break;
}
}
Ok(())
// `guard` is dropped here → terminal restored automatically.
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
use super::*;
use crate::tui::state::Screen;
fn key_press(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
/// Pressing `q` on the session-list screen shows the quit dialog.
#[test]
fn q_on_session_list_shows_quit_dialog() {
let mut state = AppState::new();
handle_event(key_press(KeyCode::Char('q')), &mut state);
assert!(state.show_quit_dialog);
assert!(!state.should_quit);
}
/// Pressing `q` on the transcript screen shows the quit dialog (same as session list).
#[test]
fn q_on_transcript_shows_quit_dialog() {
let mut state = AppState::new();
state.enter_transcript("some-session");
handle_event(key_press(KeyCode::Char('q')), &mut state);
assert!(state.show_quit_dialog);
assert!(!state.should_quit);
}
/// Pressing `Esc` on the transcript screen calls `go_back` (returns to SessionList).
#[test]
fn esc_goes_back() {
let mut state = AppState::new();
state.enter_transcript("some-session");
handle_event(key_press(KeyCode::Esc), &mut state);
assert_eq!(state.screen, Screen::SessionList);
}
/// Key-release events are ignored.
#[test]
fn key_release_ignored() {
let mut state = AppState::new();
let release_event = Event::Key(KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
});
handle_event(release_event, &mut state);
assert!(!state.should_quit);
}
/// Pressing `c` toggles `color_enabled` off then back on.
#[test]
fn c_toggles_color_enabled() {
// SAFETY: single-threaded test; no other threads reading the env.
unsafe { std::env::remove_var("NO_COLOR") };
let mut state = AppState::new();
assert!(state.color_enabled, "color starts enabled");
handle_event(key_press(KeyCode::Char('c')), &mut state);
assert!(!state.color_enabled, "color toggled off");
handle_event(key_press(KeyCode::Char('c')), &mut state);
assert!(state.color_enabled, "color toggled back on");
}
}

@ -0,0 +1,6 @@
//! TUI screen modules.
//!
//! Each sub-module owns the rendering and event-handling logic for one screen.
pub mod session_list;
pub mod transcript;

@ -0,0 +1,697 @@
//! Session-list screen: the TUI home screen.
//!
//! Renders a full-screen table of sessions and handles navigation within it.
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph, Row, Table, TableState};
use crate::filter::Filter;
use crate::tui::state::{AppState, Focus};
// ---------------------------------------------------------------------------
// Project path truncation
// ---------------------------------------------------------------------------
/// Truncate `path` so that it fits in `max_chars`, prefixing with `…` if
/// truncated. Truncates from the *left* (keeps the tail of the path, which
/// is most informative).
fn truncate_project(path: &str, max_chars: usize) -> String {
// Count chars, not bytes.
let char_count = path.chars().count();
if char_count <= max_chars {
path.to_string()
} else {
// Keep the last (max_chars - 1) chars and prepend the ellipsis.
let keep: String = path.chars().rev().take(max_chars - 1).collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("…{keep}")
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Draw the session-list screen onto `area` of the given frame.
pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
// Split the screen: top portion for the sessions table, bottom 3 rows for the
// filter bar (border top + content + border bottom).
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.split(area);
let table_area = chunks[0];
let filter_area = chunks[1];
// ── Sessions table ───────────────────────────────────────────────────────
// Column constraints:
// ID (8) | Date (20) | Project (dynamic) | Model (20) | Msgs (6) | Agents (7)
// Fixed columns total = 8 + 1 + 20 + 1 + 20 + 1 + 6 + 1 + 7 = 65 chars + borders
let widths = [
Constraint::Length(8),
Constraint::Length(20),
Constraint::Min(10), // project — gets remaining space
Constraint::Length(20),
Constraint::Length(6),
Constraint::Length(7),
];
let header = Row::new(vec!["ID", "Date", "Project", "Model", "Msgs", "Agents"])
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED));
// Compute the max width available for the project column so we can truncate.
// Fixed widths: 8+20+20+6+7 = 61, plus 5 separators = 66, plus 2 borders = 68.
// Use 30 chars as a safe default; the Min constraint will expand it.
let project_max: usize = table_area
.width
.saturating_sub(68) // subtract fixed columns + separators + borders
.max(10) as usize;
let project_display_max = project_max + 30; // generous — actual render clips
// Apply active filter to the session rows.
let active_filter = Filter::parse(&state.filter_active).ok();
let filtered_sessions: Vec<_> = state
.sessions
.iter()
.filter(|s| {
active_filter
.as_ref()
.map(|f| f.matches(*s))
.unwrap_or(true)
})
.collect();
let rows: Vec<Row> = filtered_sessions
.iter()
.map(|s| {
let project = truncate_project(&s.project, project_display_max);
Row::new(vec![
s.short_id.clone(),
s.date.clone(),
project,
s.model.clone(),
s.msg_count.to_string(),
s.agent_count.to_string(),
])
})
.collect();
let table_title = if state.filter_active.is_empty() {
" Sessions ".to_string()
} else {
format!(" Sessions [filter: {}] ", state.filter_active)
};
let block = Block::default()
.title(table_title)
.borders(Borders::ALL);
let highlight_style = Style::default().add_modifier(Modifier::REVERSED);
let table = Table::new(rows, widths)
.header(header)
.block(block)
.row_highlight_style(highlight_style)
.highlight_symbol("► ");
// Sync ratatui's TableState with our AppState selection.
let mut table_state = TableState::default();
if !filtered_sessions.is_empty() {
let clamped = state.list_selected.min(filtered_sessions.len() - 1);
table_state.select(Some(clamped));
}
f.render_stateful_widget(table, table_area, &mut table_state);
// Render an "empty" hint when there are no sessions.
if filtered_sessions.is_empty() {
let hint = Paragraph::new(Text::raw(if state.filter_active.is_empty() {
"No sessions found."
} else {
"No sessions match the current filter."
}))
.alignment(ratatui::layout::Alignment::Center);
// Place the hint in the inner area (inside the block border).
let inner = Rect {
x: table_area.x + 1,
y: table_area.y + table_area.height / 2,
width: table_area.width.saturating_sub(2),
height: 1,
};
f.render_widget(hint, inner);
}
// ── Filter bar ───────────────────────────────────────────────────────────
render_filter_bar(f, filter_area, state);
}
/// Draw the filter input bar at the bottom of the screen.
fn render_filter_bar(f: &mut Frame, area: Rect, state: &AppState) {
let focused = state.focus == Focus::FilterInput;
let border_style = if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let block = Block::default()
.title(" Filter ")
.borders(Borders::ALL)
.border_style(border_style);
// Build the content line: label + input text (+ cursor when focused).
let label = Span::styled("Filter: ", Style::default().add_modifier(Modifier::BOLD));
let input_text = if focused {
// Show a block cursor at end of input.
let mut spans = vec![label, Span::raw(state.filter_input.clone())];
spans.push(Span::styled("█", Style::default().fg(Color::Yellow)));
Line::from(spans)
} else if state.filter_input.is_empty() && state.filter_active.is_empty() {
Line::from(vec![
label,
Span::styled(
"Press 't' or Tab to focus — type a query and press Enter",
Style::default().fg(Color::DarkGray),
),
])
} else {
Line::from(vec![label, Span::raw(state.filter_input.clone())])
};
let paragraph = Paragraph::new(input_text).block(block);
f.render_widget(paragraph, area);
}
// ---------------------------------------------------------------------------
// Event handling
// ---------------------------------------------------------------------------
/// Return the sessions that match the current active filter.
///
/// If no filter is active, all sessions are returned.
fn filtered_session_indices(state: &AppState) -> Vec<usize> {
let active_filter = Filter::parse(&state.filter_active).ok();
state
.sessions
.iter()
.enumerate()
.filter(|(_, s)| {
active_filter
.as_ref()
.map(|f| f.matches(*s))
.unwrap_or(true)
})
.map(|(i, _)| i)
.collect()
}
/// Handle a crossterm [`Event`] for the session-list screen.
///
/// Returns `true` if the event was consumed (the caller should not process it
/// further), `false` if it was not handled by this screen.
pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
let Event::Key(key) = event else {
return false;
};
if key.kind != KeyEventKind::Press {
return false;
}
// When the filter input is focused, handle filter-specific keys first.
if state.focus == Focus::FilterInput {
return handle_filter_input_event(key.code, state);
}
match key.code {
// Navigate up.
KeyCode::Up | KeyCode::Char('k') => {
state.list_selected = state.list_selected.saturating_sub(1);
true
}
// Navigate down.
KeyCode::Down | KeyCode::Char('j') => {
let visible = filtered_session_indices(state);
if !visible.is_empty() {
state.list_selected =
(state.list_selected + 1).min(visible.len() - 1);
}
true
}
// Enter session transcript.
KeyCode::Enter => {
let visible = filtered_session_indices(state);
let clamped = state.list_selected.min(visible.len().saturating_sub(1));
if let Some(&real_idx) = visible.get(clamped) {
let full_id = state.sessions[real_idx].full_id.clone();
state.enter_transcript(full_id);
}
true
}
// Focus the filter input directly.
KeyCode::Char('t') => {
state.focus = Focus::FilterInput;
true
}
// Tab cycles focus: list → filter → list.
KeyCode::Tab => {
state.focus = Focus::FilterInput;
true
}
// Quit — show confirmation dialog rather than exiting immediately.
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
state.show_quit_dialog = true;
true
}
// Help overlay.
KeyCode::Char('?') => {
state.show_help = true;
true
}
_ => false,
}
}
/// Handle a key event while the filter input bar is focused.
///
/// Returns `true` when the event is consumed.
fn handle_filter_input_event(code: KeyCode, state: &mut AppState) -> bool {
match code {
// Character input: append to filter_input and reset history browsing.
KeyCode::Char(c) => {
state.filter_input.push(c);
state.filter_history_pos = None;
true
}
// Backspace: remove last char.
KeyCode::Backspace => {
state.filter_input.pop();
state.filter_history_pos = None;
true
}
// Enter: apply the current input as the active filter and return focus to the list.
KeyCode::Enter => {
let query = state.filter_input.trim().to_string();
// Record in history (skip duplicate consecutive entries).
if !query.is_empty() {
let is_dup = state.filter_history.last().map(|l| l == &query).unwrap_or(false);
if !is_dup {
state.filter_history.push(query.clone());
AppState::append_history_to_disk(&query);
}
}
state.filter_active = query;
state.filter_history_pos = None;
// Reset list selection when filter changes.
state.list_selected = 0;
// Return focus to the main list.
state.focus = Focus::ChatLog;
true
}
// Escape: clear the text input (but keep the panel visible).
KeyCode::Esc => {
state.filter_input.clear();
state.filter_history_pos = None;
true
}
// Up arrow: browse back through history.
KeyCode::Up => {
if state.filter_history.is_empty() {
return true;
}
let new_pos = match state.filter_history_pos {
None => state.filter_history.len() - 1,
Some(p) => p.saturating_sub(1),
};
state.filter_history_pos = Some(new_pos);
state.filter_input = state.filter_history[new_pos].clone();
true
}
// Down arrow: browse forward through history (or clear when past the end).
KeyCode::Down => {
let Some(pos) = state.filter_history_pos else {
return true;
};
if pos + 1 < state.filter_history.len() {
let new_pos = pos + 1;
state.filter_history_pos = Some(new_pos);
state.filter_input = state.filter_history[new_pos].clone();
} else {
// Past the end: clear input and stop browsing history.
state.filter_history_pos = None;
state.filter_input.clear();
}
true
}
// Tab: cycle focus back to the list.
KeyCode::Tab => {
state.focus = Focus::ChatLog;
true
}
_ => true, // consume all other keys while filter is focused
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
use super::*;
use crate::tui::state::{Screen, SessionListItem};
fn make_item(short_id: &str, full_id: &str) -> SessionListItem {
SessionListItem {
short_id: short_id.to_string(),
full_id: full_id.to_string(),
date: "2026-03-29 14:32:01".to_string(),
project: "/home/user/project".to_string(),
model: "claude-sonnet-4-6".to_string(),
msg_count: 10,
agent_count: 2,
}
}
fn press(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
fn release(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
})
}
// ── truncate_project ────────────────────────────────────────────────────
#[test]
fn truncate_short_path_unchanged() {
assert_eq!(truncate_project("/home/user", 30), "/home/user");
}
#[test]
fn truncate_exact_length_unchanged() {
let path = "a".repeat(30);
assert_eq!(truncate_project(&path, 30), path);
}
#[test]
fn truncate_long_path_prefixed_with_ellipsis() {
let path = "/very/long/path/that/exceeds/the/limit/foo/bar";
let result = truncate_project(path, 20);
assert!(result.starts_with('…'));
assert_eq!(result.chars().count(), 20);
}
// ── handle_session_list_event ───────────────────────────────────────────
#[test]
fn down_increments_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.list_selected = 0;
let consumed = handle_session_list_event(press(KeyCode::Down), &mut state);
assert!(consumed);
assert_eq!(state.list_selected, 1);
}
#[test]
fn down_clamps_at_end() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.list_selected = 0;
handle_session_list_event(press(KeyCode::Down), &mut state);
// Should remain at 0 (only one item).
assert_eq!(state.list_selected, 0);
}
#[test]
fn up_decrements_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.list_selected = 1;
let consumed = handle_session_list_event(press(KeyCode::Up), &mut state);
assert!(consumed);
assert_eq!(state.list_selected, 0);
}
#[test]
fn up_clamps_at_zero() {
let mut state = AppState::new();
state.list_selected = 0;
handle_session_list_event(press(KeyCode::Up), &mut state);
assert_eq!(state.list_selected, 0);
}
#[test]
fn j_increments_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.list_selected = 0;
handle_session_list_event(press(KeyCode::Char('j')), &mut state);
assert_eq!(state.list_selected, 1);
}
#[test]
fn k_decrements_selection() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
state.sessions.push(make_item("bbbbbbbb", "bbbbbbbb-0000-0000-0000-000000000000"));
state.list_selected = 1;
handle_session_list_event(press(KeyCode::Char('k')), &mut state);
assert_eq!(state.list_selected, 0);
}
#[test]
fn enter_transitions_to_transcript() {
let mut state = AppState::new();
let full = "aaaaaaaa-0000-0000-0000-000000000000";
state.sessions.push(make_item("aaaaaaaa", full));
state.list_selected = 0;
let consumed = handle_session_list_event(press(KeyCode::Enter), &mut state);
assert!(consumed);
assert_eq!(
state.screen,
Screen::Transcript {
session_id: full.to_string()
}
);
}
#[test]
fn enter_on_empty_sessions_does_not_crash() {
let mut state = AppState::new();
let consumed = handle_session_list_event(press(KeyCode::Enter), &mut state);
assert!(consumed);
assert_eq!(state.screen, Screen::SessionList);
}
#[test]
fn q_shows_quit_dialog() {
let mut state = AppState::new();
let consumed = handle_session_list_event(press(KeyCode::Char('q')), &mut state);
assert!(consumed);
assert!(state.show_quit_dialog);
assert!(!state.should_quit);
}
#[test]
fn big_q_shows_quit_dialog() {
let mut state = AppState::new();
handle_session_list_event(press(KeyCode::Char('Q')), &mut state);
assert!(state.show_quit_dialog);
assert!(!state.should_quit);
}
#[test]
fn esc_shows_quit_dialog() {
let mut state = AppState::new();
handle_session_list_event(press(KeyCode::Esc), &mut state);
assert!(state.show_quit_dialog);
assert!(!state.should_quit);
}
#[test]
fn question_mark_shows_help() {
let mut state = AppState::new();
let consumed = handle_session_list_event(press(KeyCode::Char('?')), &mut state);
assert!(consumed);
assert!(state.show_help);
}
#[test]
fn unhandled_key_returns_false() {
let mut state = AppState::new();
let consumed = handle_session_list_event(press(KeyCode::Char('x')), &mut state);
assert!(!consumed);
}
#[test]
fn key_release_not_consumed() {
let mut state = AppState::new();
let consumed = handle_session_list_event(release(KeyCode::Char('q')), &mut state);
assert!(!consumed);
assert!(!state.should_quit);
}
#[test]
fn non_key_event_not_consumed() {
let mut state = AppState::new();
let consumed = handle_session_list_event(Event::FocusGained, &mut state);
assert!(!consumed);
}
// ── Filter input focus ───────────────────────────────────────────────────
#[test]
fn t_key_focuses_filter_input() {
let mut state = AppState::new();
assert_eq!(state.focus, Focus::ChatLog);
let consumed = handle_session_list_event(press(KeyCode::Char('t')), &mut state);
assert!(consumed);
assert_eq!(state.focus, Focus::FilterInput);
}
#[test]
fn tab_focuses_filter_input() {
let mut state = AppState::new();
let consumed = handle_session_list_event(press(KeyCode::Tab), &mut state);
assert!(consumed);
assert_eq!(state.focus, Focus::FilterInput);
}
#[test]
fn tab_from_filter_cycles_back_to_list() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
handle_session_list_event(press(KeyCode::Tab), &mut state);
assert_eq!(state.focus, Focus::ChatLog);
}
// ── Filter character input ───────────────────────────────────────────────
#[test]
fn char_input_appends_to_filter_input() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
handle_session_list_event(press(KeyCode::Char('m')), &mut state);
handle_session_list_event(press(KeyCode::Char('o')), &mut state);
assert_eq!(state.filter_input, "mo");
}
#[test]
fn backspace_removes_last_char() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_input = "mo".to_string();
handle_session_list_event(press(KeyCode::Backspace), &mut state);
assert_eq!(state.filter_input, "m");
}
#[test]
fn esc_clears_filter_input_stays_focused() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_input = "foo".to_string();
handle_session_list_event(press(KeyCode::Esc), &mut state);
assert_eq!(state.filter_input, "");
assert_eq!(state.focus, Focus::FilterInput);
}
#[test]
fn enter_applies_filter_and_returns_focus_to_list() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_input = "model:haiku".to_string();
handle_session_list_event(press(KeyCode::Enter), &mut state);
assert_eq!(state.filter_active, "model:haiku");
assert_eq!(state.focus, Focus::ChatLog);
// Should be in history now.
assert!(state.filter_history.contains(&"model:haiku".to_string()));
}
#[test]
fn enter_empty_clears_active_filter() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_active = "model:haiku".to_string();
state.filter_input = "".to_string();
handle_session_list_event(press(KeyCode::Enter), &mut state);
assert_eq!(state.filter_active, "");
}
#[test]
fn duplicate_consecutive_entry_not_added_to_history() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_input = "model:haiku".to_string();
handle_session_list_event(press(KeyCode::Enter), &mut state);
let count_before = state.filter_history.len();
state.focus = Focus::FilterInput;
state.filter_input = "model:haiku".to_string();
handle_session_list_event(press(KeyCode::Enter), &mut state);
assert_eq!(state.filter_history.len(), count_before);
}
// ── Filter history navigation ────────────────────────────────────────────
#[test]
fn up_cycles_through_history() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_history = vec!["first".to_string(), "second".to_string()];
handle_session_list_event(press(KeyCode::Up), &mut state);
assert_eq!(state.filter_input, "second");
assert_eq!(state.filter_history_pos, Some(1));
handle_session_list_event(press(KeyCode::Up), &mut state);
assert_eq!(state.filter_input, "first");
assert_eq!(state.filter_history_pos, Some(0));
}
#[test]
fn down_goes_forward_in_history() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_history = vec!["first".to_string(), "second".to_string()];
state.filter_history_pos = Some(0);
state.filter_input = "first".to_string();
handle_session_list_event(press(KeyCode::Down), &mut state);
assert_eq!(state.filter_input, "second");
assert_eq!(state.filter_history_pos, Some(1));
}
#[test]
fn down_past_end_clears_input() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_history = vec!["first".to_string()];
state.filter_history_pos = Some(0);
state.filter_input = "first".to_string();
handle_session_list_event(press(KeyCode::Down), &mut state);
assert_eq!(state.filter_input, "");
assert_eq!(state.filter_history_pos, None);
}
}

@ -0,0 +1,961 @@
//! Transcript screen: full-screen chat log with a stats header.
//!
//! Renders the session or sub-agent conversation with a fixed stats header at
//! the top (session ID, model, token counts, duration, tool call summary) and
//! a scrollable chat log below.
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, Wrap};
use crate::models::session::{ContentBlock, MessageContent, RawEntry};
use crate::models::stats::compute_stats;
use crate::tui::state::{AppState, Focus};
use crate::util::short_id;
// ---------------------------------------------------------------------------
// Stats header helpers
// ---------------------------------------------------------------------------
/// Format a token count in compact "Xk" / "X" form.
fn fmt_k(n: u64) -> String {
if n >= 1000 {
format!("{}k", n / 1000)
} else {
n.to_string()
}
}
/// Format a duration in milliseconds as "Xmin" / "Xs" / "Xms".
fn fmt_duration(ms: u64) -> String {
if ms >= 60_000 {
format!("{}min", ms / 60_000)
} else if ms >= 1_000 {
format!("{}s", ms / 1_000)
} else {
format!("{ms}ms")
}
}
/// Build the two-line stats header text from entries.
///
/// Line 1: `Session: <short_id> Model: <model> Tokens: in=<N> out=<N> Duration: <dur>`
/// Line 2: `Tools: Name×count, Name×count, …` (omitted if no tool calls)
fn build_header_lines(session_id: &str, entries: &[RawEntry]) -> Vec<Line<'static>> {
let stats = compute_stats(entries);
let model = stats.model.as_deref().unwrap_or("unknown").to_string();
let short = short_id(session_id).to_string();
let line1 = format!(
"Session: {} Model: {} Tokens: in={} out={} Duration: {}",
short,
model,
fmt_k(stats.input_tokens),
fmt_k(stats.output_tokens),
fmt_duration(stats.duration_ms),
);
let mut lines = vec![Line::from(Span::styled(
line1,
Style::default().add_modifier(Modifier::BOLD),
))];
if !stats.tool_calls.is_empty() {
// Sort by count descending, then name ascending for determinism.
let mut tool_pairs: Vec<(String, u64)> = stats.tool_calls.into_iter().collect();
tool_pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let tools_str = tool_pairs
.iter()
.map(|(name, count)| format!("{name}\u{00d7}{count}"))
.collect::<Vec<_>>()
.join(", ");
lines.push(Line::from(format!("Tools: {tools_str}")));
}
lines
}
// ---------------------------------------------------------------------------
// Chat log helpers
// ---------------------------------------------------------------------------
const TOOL_RESULT_TRUNCATE: usize = 200;
const TOOL_INPUT_TRUNCATE: usize = 120;
/// Convert a slice of [`RawEntry`] values into ratatui [`Line`] values for display.
///
/// Thinking blocks are skipped. Tool results are truncated to [`TOOL_RESULT_TRUNCATE`]
/// chars. Tool inputs are truncated to [`TOOL_INPUT_TRUNCATE`] chars.
///
/// When `color_enabled` is `false` all spans are rendered without colour styling.
pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
for entry in entries {
let Some(msg) = &entry.message else {
continue;
};
let role = msg.role.as_deref().unwrap_or("?").to_string();
// Choose a ratatui role label style based on the role name.
let role_style = if color_enabled {
match role.as_str() {
"assistant" => Style::default().fg(Color::Rgb(255, 140, 0)), // orange
"user" => Style::default().fg(Color::Rgb(170, 170, 170)), // grey
_ => Style::default(),
}
} else {
Style::default()
};
match &msg.content {
None => {}
Some(MessageContent::Text(t)) => {
let prefix_label = format!("[{role}]: ");
let text = t.clone();
// Split on newlines so each source line becomes its own ratatui Line.
let mut first = true;
for src_line in text.lines() {
if first {
first = false;
lines.push(Line::from(vec![
Span::styled(prefix_label.clone(), role_style),
Span::raw(src_line.to_string()),
]));
} else {
// Indent continuation lines by the prefix width.
lines.push(Line::from(format!(
"{}{src_line}",
" ".repeat(prefix_label.len())
)));
}
}
if first {
// Empty text — still emit the prefix.
lines.push(Line::from(Span::styled(prefix_label, role_style)));
}
}
Some(MessageContent::Blocks(blocks)) => {
for block in blocks {
match block {
ContentBlock::Thinking { .. } => {
// Hidden by default.
}
ContentBlock::Text { text } => {
let prefix_label = format!("[{role}]: ");
let mut first = true;
for src_line in text.lines() {
if first {
first = false;
lines.push(Line::from(vec![
Span::styled(prefix_label.clone(), role_style),
Span::raw(src_line.to_string()),
]));
} else {
lines.push(Line::from(format!(
"{}{src_line}",
" ".repeat(prefix_label.len())
)));
}
}
if first {
lines.push(Line::from(Span::styled(prefix_label, role_style)));
}
}
ContentBlock::ToolUse { name, input, .. } => {
let input_str =
serde_json::to_string(input).unwrap_or_default();
let truncated = truncate_str(&input_str, TOOL_INPUT_TRUNCATE);
let ellipsis =
if input_str.len() > TOOL_INPUT_TRUNCATE { "…" } else { "" };
let tool_style = if color_enabled {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
lines.push(Line::from(Span::styled(
format!("[tool: {name}] {truncated}{ellipsis}"),
tool_style,
)));
}
ContentBlock::ToolResult {
content, is_error, ..
} => {
let err_flag =
if is_error.unwrap_or(false) { " (error)" } else { "" };
let preview = content
.as_ref()
.and_then(|c| c.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| {
content
.as_ref()
.map(|c| c.to_string())
.unwrap_or_default()
});
let truncated = truncate_str(&preview, TOOL_RESULT_TRUNCATE);
let ellipsis =
if preview.len() > TOOL_RESULT_TRUNCATE { "…" } else { "" };
let style = if color_enabled {
if is_error.unwrap_or(false) {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::Green)
}
} else {
Style::default()
};
lines.push(Line::from(Span::styled(
format!("[tool_result{err_flag}]: {truncated}{ellipsis}"),
style,
)));
}
ContentBlock::Image { .. } => {
let img_style = if color_enabled {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
lines.push(Line::from(Span::styled(
"[image]".to_string(),
img_style,
)));
}
ContentBlock::Unknown => {
lines.push(Line::from("[unknown block]".to_string()));
}
}
}
}
}
}
lines
}
/// Truncate a string to at most `max_chars` chars (Unicode-safe).
fn truncate_str(s: &str, max_chars: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_chars {
s.to_string()
} else {
s.chars().take(max_chars).collect()
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
/// Draw the transcript screen onto `area` of the given frame.
///
/// Layout:
/// - Top 4 lines: stats header (inside a bordered block).
/// - Remaining lines: scrollable chat log.
pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
// Determine the session_id for the header.
let session_id = match &state.screen {
crate::tui::state::Screen::Transcript { session_id } => session_id.clone(),
crate::tui::state::Screen::SubagentTranscript { agent_id, .. } => agent_id.clone(),
_ => String::new(),
};
// Split area: header (fixed) | chat log (min).
// Header block: 2 content lines + 2 border lines = 4 rows total.
let header_height: u16 = 4;
let chunks = Layout::vertical([
Constraint::Length(header_height),
Constraint::Min(1),
])
.split(area);
// ── Stats header ────────────────────────────────────────────────────────
let header_lines = build_header_lines(&session_id, &state.transcript_entries);
let header_text = Text::from(header_lines);
let header_block = Block::default()
.title(" Session Stats ")
.borders(Borders::ALL);
let header_paragraph = Paragraph::new(header_text)
.block(header_block)
.wrap(Wrap { trim: false });
f.render_widget(header_paragraph, chunks[0]);
// ── Chat log + sub-agents panel (horizontal split) ──────────────────────
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(30)])
.split(chunks[1]);
// ── Chat log ─────────────────────────────────────────────────────────────
let chat_lines = build_chat_lines(&state.transcript_entries, state.color_enabled);
let chat_border_style = if state.focus == Focus::ChatLog {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let chat_block = Block::default()
.title(" Transcript [↑/↓ scroll · ←/→ h-scroll · Tab focus · Esc back · ? help] ")
.borders(Borders::ALL)
.border_style(chat_border_style);
let chat_paragraph = Paragraph::new(Text::from(chat_lines))
.block(chat_block)
.scroll((state.transcript_scroll as u16, state.transcript_h_scroll as u16));
f.render_widget(chat_paragraph, body_chunks[0]);
// ── Sub-agents panel ─────────────────────────────────────────────────────
let subagents_border_style = if state.focus == Focus::SubagentsPanel {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let subagents_block = Block::default()
.title(" Sub-agents ")
.borders(Borders::ALL)
.border_style(subagents_border_style);
let subagent_lines: Vec<Line<'static>> = if state.subagents.is_empty() {
vec![Line::from(" No sub-agents")]
} else {
state
.subagents
.iter()
.enumerate()
.map(|(i, agent)| {
let short_id = if agent.agent_id.len() >= 8 {
agent.agent_id[..8].to_string()
} else {
agent.agent_id.clone()
};
let agent_type = agent
.agent_type
.as_deref()
.unwrap_or("agent")
.to_string();
if i == state.subagent_selected && state.focus == Focus::SubagentsPanel {
Line::from(Span::styled(
format!("> {short_id} {agent_type}"),
Style::default().fg(Color::Yellow),
))
} else {
Line::from(format!(" {short_id} {agent_type}"))
}
})
.collect()
};
let subagents_paragraph = Paragraph::new(Text::from(subagent_lines))
.block(subagents_block);
f.render_widget(subagents_paragraph, body_chunks[1]);
}
// ---------------------------------------------------------------------------
// Event handling
// ---------------------------------------------------------------------------
/// Handle a crossterm [`Event`] for the transcript screen.
///
/// Returns `true` if the event was consumed, `false` otherwise.
pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
let Event::Key(key) = event else {
return false;
};
if key.kind != KeyEventKind::Press {
return false;
}
match key.code {
// Toggle focus between ChatLog and SubagentsPanel.
KeyCode::Tab => {
state.focus = match state.focus {
Focus::ChatLog => Focus::SubagentsPanel,
Focus::SubagentsPanel | Focus::FilterInput => Focus::ChatLog,
};
true
}
// Navigate back to the session list.
KeyCode::Esc => {
state.go_back();
true
}
// Show quit dialog (don't exit immediately on transcript screen).
KeyCode::Char('q') | KeyCode::Char('Q') => {
state.show_quit_dialog = true;
true
}
// Show help overlay.
KeyCode::Char('?') => {
state.show_help = true;
true
}
// Directional keys are routed based on the focused panel.
KeyCode::Up | KeyCode::Char('k') => match state.focus {
Focus::SubagentsPanel => {
state.subagent_selected = state.subagent_selected.saturating_sub(1);
true
}
Focus::ChatLog | Focus::FilterInput => {
state.transcript_scroll = state.transcript_scroll.saturating_sub(1);
true
}
},
KeyCode::Down | KeyCode::Char('j') => match state.focus {
Focus::SubagentsPanel => {
if !state.subagents.is_empty() {
let max = state.subagents.len() - 1;
state.subagent_selected =
(state.subagent_selected + 1).min(max);
}
true
}
Focus::ChatLog | Focus::FilterInput => {
state.transcript_scroll = state.transcript_scroll.saturating_add(1);
true
}
},
// Horizontal scroll — only meaningful in ChatLog.
KeyCode::Left | KeyCode::Char('h') => {
state.transcript_h_scroll = state.transcript_h_scroll.saturating_sub(1);
true
}
KeyCode::Right | KeyCode::Char('l') => {
state.transcript_h_scroll = state.transcript_h_scroll.saturating_add(1);
true
}
// Enter on a selected sub-agent — navigate into its transcript.
KeyCode::Enter => {
if state.focus == Focus::SubagentsPanel && !state.subagents.is_empty() {
let agent = state.subagents[state.subagent_selected].clone();
let parent_session_id = agent.session_id.clone();
let agent_id = agent.agent_id.clone();
state.enter_subagent_transcript(parent_session_id, agent_id);
// Load the transcript for the selected agent.
let agent_id_for_load = agent.agent_id.clone();
let parent_for_load = agent.session_id.clone();
load_transcript_for_agent(&parent_for_load, &agent_id_for_load, state);
true
} else {
false
}
}
_ => false,
}
}
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
/// Load transcript entries for a session ID into `state.transcript_entries`.
///
/// Walks `~/.claude/projects/` to find the JSONL file for `session_id`, then
/// reads and parses it synchronously (blocking the current tokio runtime handle).
///
/// Any discovery or I/O errors are silently ignored — the transcript will
/// simply be empty, which the renderer handles gracefully.
pub fn load_transcript_for_session(session_id: &str, state: &mut AppState) {
let sessions = match crate::parser::discovery::discover_sessions() {
Ok(s) => s,
Err(_) => return,
};
// Find the session whose ID matches (supports full UUID or 8-char prefix).
let session_ref = sessions.into_iter().find(|s| {
s.session_id == session_id || s.session_id.starts_with(session_id)
});
let Some(sr) = session_ref else { return };
// Discover sub-agents for the sidebar (best-effort).
if let Ok(agents) =
crate::parser::discovery::discover_agents_for_session(&sr.file_path)
{
state.subagents = agents;
}
// Read the JSONL file; block on the async reader using the current runtime.
let handle = tokio::runtime::Handle::current();
let file_path = sr.file_path.clone();
if let Ok(entries) = handle.block_on(crate::parser::reader::read_session_file(&file_path)) {
state.transcript_entries = entries;
}
}
/// Load transcript entries for a sub-agent into `state.transcript_entries`.
///
/// Walks all discovered agents to find the one whose `agent_id` matches, then
/// reads and parses its JSONL file.
pub fn load_transcript_for_agent(
parent_session_id: &str,
agent_id: &str,
state: &mut AppState,
) {
let sessions = match crate::parser::discovery::discover_sessions() {
Ok(s) => s,
Err(_) => return,
};
// Find the parent session.
let session_ref = sessions.into_iter().find(|s| {
s.session_id == parent_session_id
|| s.session_id.starts_with(parent_session_id)
});
let Some(sr) = session_ref else { return };
// Find the agent under that session.
let agents = match crate::parser::discovery::discover_agents_for_session(&sr.file_path) {
Ok(a) => a,
Err(_) => return,
};
let agent_ref = agents
.into_iter()
.find(|a| a.agent_id == agent_id || a.agent_id.starts_with(agent_id));
let Some(ar) = agent_ref else { return };
let handle = tokio::runtime::Handle::current();
let file_path = ar.file_path.clone();
if let Ok(entries) = handle.block_on(crate::parser::reader::read_session_file(&file_path)) {
state.transcript_entries = entries;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::models::session::{ContentBlock, Message, MessageContent, RawEntry};
fn make_entry(entry_type: &str) -> RawEntry {
RawEntry {
entry_type: Some(entry_type.to_string()),
session_id: None,
parent_session_id: None,
message: None,
system_message: None,
cwd: None,
timestamp: None,
duration_ms: None,
extra: Default::default(),
}
}
fn user_text_entry(text: &str) -> RawEntry {
RawEntry {
entry_type: Some("user".to_string()),
message: Some(Message {
role: Some("user".to_string()),
content: Some(MessageContent::Text(text.to_string())),
usage: None,
model: None,
stop_reason: None,
}),
..make_entry("user")
}
}
fn assistant_text_entry(text: &str) -> RawEntry {
RawEntry {
entry_type: Some("assistant".to_string()),
message: Some(Message {
role: Some("assistant".to_string()),
content: Some(MessageContent::Text(text.to_string())),
usage: None,
model: None,
stop_reason: None,
}),
..make_entry("assistant")
}
}
// ── fmt_k ───────────────────────────────────────────────────────────────
#[test]
fn fmt_k_below_thousand() {
assert_eq!(fmt_k(999), "999");
}
#[test]
fn fmt_k_exactly_thousand() {
assert_eq!(fmt_k(1000), "1k");
}
#[test]
fn fmt_k_above_thousand() {
assert_eq!(fmt_k(12_345), "12k");
}
// ── fmt_duration ─────────────────────────────────────────────────────────
#[test]
fn fmt_duration_ms() {
assert_eq!(fmt_duration(999), "999ms");
}
#[test]
fn fmt_duration_seconds() {
assert_eq!(fmt_duration(3_500), "3s");
}
#[test]
fn fmt_duration_minutes() {
assert_eq!(fmt_duration(125_000), "2min");
}
// ── truncate_str ──────────────────────────────────────────────────────────
#[test]
fn truncate_str_short() {
assert_eq!(truncate_str("hello", 10), "hello");
}
#[test]
fn truncate_str_exact() {
assert_eq!(truncate_str("hello", 5), "hello");
}
#[test]
fn truncate_str_long() {
let s = "abcdefghij";
assert_eq!(truncate_str(s, 5), "abcde");
}
// ── build_header_lines ───────────────────────────────────────────────────
#[test]
fn header_lines_empty_entries() {
let lines = build_header_lines("abc12345-0000-0000-0000-000000000000", &[]);
// Should have 1 line (no tool calls line when empty).
assert_eq!(lines.len(), 1);
let text: String = lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(text.contains("abc12345"));
assert!(text.contains("Tokens: in=0 out=0"));
}
#[test]
fn header_lines_with_tool_calls() {
let mut entry = make_entry("assistant");
entry.message = Some(Message {
role: Some("assistant".to_string()),
content: Some(MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "t1".to_string(),
name: "Bash".to_string(),
input: serde_json::Value::Null,
}])),
usage: None,
model: Some("claude-opus".to_string()),
stop_reason: None,
});
let lines = build_header_lines("abc12345", &[entry]);
assert_eq!(lines.len(), 2);
let tools_text: String = lines[1]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(tools_text.contains("Bash"));
}
// ── build_chat_lines ─────────────────────────────────────────────────────
#[test]
fn chat_lines_empty_entries() {
let lines = build_chat_lines(&[], false);
assert!(lines.is_empty());
}
#[test]
fn chat_lines_user_text() {
let entry = user_text_entry("Hello, Claude!");
let lines = build_chat_lines(&[entry], false);
assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("[user]: Hello, Claude!"));
}
#[test]
fn chat_lines_assistant_text() {
let entry = assistant_text_entry("Here is my answer.");
let lines = build_chat_lines(&[entry], false);
assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("[assistant]: Here is my answer."));
}
#[test]
fn chat_lines_multiline_text() {
let entry = user_text_entry("line1\nline2\nline3");
let lines = build_chat_lines(&[entry], false);
assert_eq!(lines.len(), 3);
}
#[test]
fn chat_lines_thinking_skipped() {
let mut entry = make_entry("assistant");
entry.message = Some(Message {
role: Some("assistant".to_string()),
content: Some(MessageContent::Blocks(vec![
ContentBlock::Thinking {
thinking: "hidden".to_string(),
},
ContentBlock::Text {
text: "visible".to_string(),
},
])),
usage: None,
model: None,
stop_reason: None,
});
let lines = build_chat_lines(&[entry], false);
// Only the Text block should produce a line; Thinking is skipped.
assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("visible"));
assert!(!text.contains("hidden"));
}
#[test]
fn chat_lines_tool_use() {
let mut entry = make_entry("assistant");
entry.message = Some(Message {
role: Some("assistant".to_string()),
content: Some(MessageContent::Blocks(vec![ContentBlock::ToolUse {
id: "t1".to_string(),
name: "Read".to_string(),
input: serde_json::json!({"file_path": "/foo/bar.rs"}),
}])),
usage: None,
model: None,
stop_reason: None,
});
let lines = build_chat_lines(&[entry], false);
assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("[tool: Read]"));
assert!(text.contains("/foo/bar.rs"));
}
#[test]
fn chat_lines_tool_result_truncated() {
let long_content = "x".repeat(300);
let mut entry = make_entry("user");
entry.message = Some(Message {
role: Some("user".to_string()),
content: Some(MessageContent::Blocks(vec![ContentBlock::ToolResult {
tool_use_id: "t1".to_string(),
content: Some(serde_json::Value::String(long_content)),
is_error: Some(false),
}])),
usage: None,
model: None,
stop_reason: None,
});
let lines = build_chat_lines(&[entry], false);
assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
// Should contain the ellipsis marker.
assert!(text.contains('…'));
}
#[test]
fn chat_lines_entries_without_message_skipped() {
// A "system" entry with no message field should produce no chat lines.
let entry = make_entry("system");
let lines = build_chat_lines(&[entry], false);
assert!(lines.is_empty());
}
// ── handle_transcript_event ───────────────────────────────────────────────
use ratatui::crossterm::event::{KeyEvent, KeyEventState, KeyModifiers};
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,
})
}
fn transcript_state() -> AppState {
let mut state = AppState::new();
state.enter_transcript("abc12345-0000-0000-0000-000000000000");
state
}
#[test]
fn up_decrements_scroll() {
let mut state = transcript_state();
state.transcript_scroll = 5;
let consumed = handle_transcript_event(press(KeyCode::Up), &mut state);
assert!(consumed);
assert_eq!(state.transcript_scroll, 4);
}
#[test]
fn k_decrements_scroll() {
let mut state = transcript_state();
state.transcript_scroll = 3;
handle_transcript_event(press(KeyCode::Char('k')), &mut state);
assert_eq!(state.transcript_scroll, 2);
}
#[test]
fn up_clamps_at_zero() {
let mut state = transcript_state();
state.transcript_scroll = 0;
handle_transcript_event(press(KeyCode::Up), &mut state);
assert_eq!(state.transcript_scroll, 0);
}
#[test]
fn down_increments_scroll() {
let mut state = transcript_state();
state.transcript_scroll = 0;
let consumed = handle_transcript_event(press(KeyCode::Down), &mut state);
assert!(consumed);
assert_eq!(state.transcript_scroll, 1);
}
#[test]
fn j_increments_scroll() {
let mut state = transcript_state();
state.transcript_scroll = 0;
handle_transcript_event(press(KeyCode::Char('j')), &mut state);
assert_eq!(state.transcript_scroll, 1);
}
#[test]
fn left_decrements_h_scroll() {
let mut state = transcript_state();
state.transcript_h_scroll = 4;
let consumed = handle_transcript_event(press(KeyCode::Left), &mut state);
assert!(consumed);
assert_eq!(state.transcript_h_scroll, 3);
}
#[test]
fn h_decrements_h_scroll() {
let mut state = transcript_state();
state.transcript_h_scroll = 2;
handle_transcript_event(press(KeyCode::Char('h')), &mut state);
assert_eq!(state.transcript_h_scroll, 1);
}
#[test]
fn left_clamps_at_zero() {
let mut state = transcript_state();
state.transcript_h_scroll = 0;
handle_transcript_event(press(KeyCode::Left), &mut state);
assert_eq!(state.transcript_h_scroll, 0);
}
#[test]
fn right_increments_h_scroll() {
let mut state = transcript_state();
state.transcript_h_scroll = 0;
let consumed = handle_transcript_event(press(KeyCode::Right), &mut state);
assert!(consumed);
assert_eq!(state.transcript_h_scroll, 1);
}
#[test]
fn l_increments_h_scroll() {
let mut state = transcript_state();
state.transcript_h_scroll = 0;
handle_transcript_event(press(KeyCode::Char('l')), &mut state);
assert_eq!(state.transcript_h_scroll, 1);
}
#[test]
fn tab_toggles_focus() {
let mut state = transcript_state();
assert_eq!(state.focus, Focus::ChatLog);
handle_transcript_event(press(KeyCode::Tab), &mut state);
assert_eq!(state.focus, Focus::SubagentsPanel);
handle_transcript_event(press(KeyCode::Tab), &mut state);
assert_eq!(state.focus, Focus::ChatLog);
}
#[test]
fn esc_goes_back() {
let mut state = transcript_state();
let consumed = handle_transcript_event(press(KeyCode::Esc), &mut state);
assert!(consumed);
assert_eq!(state.screen, crate::tui::state::Screen::SessionList);
}
#[test]
fn q_shows_quit_dialog() {
let mut state = transcript_state();
let consumed = handle_transcript_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 = transcript_state();
handle_transcript_event(press(KeyCode::Char('Q')), &mut state);
assert!(state.show_quit_dialog);
}
#[test]
fn question_mark_shows_help() {
let mut state = transcript_state();
let consumed = handle_transcript_event(press(KeyCode::Char('?')), &mut state);
assert!(consumed);
assert!(state.show_help);
}
#[test]
fn unhandled_key_not_consumed() {
let mut state = transcript_state();
let consumed = handle_transcript_event(press(KeyCode::Char('x')), &mut state);
assert!(!consumed);
}
#[test]
fn key_release_not_consumed() {
let mut state = transcript_state();
let consumed = handle_transcript_event(release(KeyCode::Up), &mut state);
assert!(!consumed);
}
#[test]
fn non_key_event_not_consumed() {
let mut state = transcript_state();
let consumed = handle_transcript_event(Event::FocusGained, &mut state);
assert!(!consumed);
}
}

@ -4,6 +4,10 @@
//! that drive the TUI navigation model. This module holds pure data — no //! that drive the TUI navigation model. This module holds pure data — no
//! terminal I/O or rendering logic lives here. //! terminal I/O or rendering logic lives here.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use crate::models::session::RawEntry; use crate::models::session::RawEntry;
use crate::parser::discovery::AgentRef; use crate::parser::discovery::AgentRef;
@ -67,18 +71,15 @@ pub enum Screen {
/// ///
/// On the session-list screen `Focus` is not meaningful; it is tracked here /// On the session-list screen `Focus` is not meaningful; it is tracked here
/// so the value is preserved when the user navigates back and forth. /// so the value is preserved when the user navigates back and forth.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Focus { pub enum Focus {
/// The main chat-log pane (default). /// The main chat-log pane (default).
#[default]
ChatLog, ChatLog,
/// The sub-agents side panel (shown when sub-agents are present). /// The sub-agents side panel (shown when sub-agents are present).
SubagentsPanel, SubagentsPanel,
} /// The filter input bar at the bottom of the session-list screen.
FilterInput,
impl Default for Focus {
fn default() -> Self {
Self::ChatLog
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -124,15 +125,81 @@ pub struct AppState {
pub show_quit_dialog: bool, pub show_quit_dialog: bool,
/// Whether the keyboard-shortcut help overlay is visible. /// Whether the keyboard-shortcut help overlay is visible.
pub show_help: bool, pub show_help: bool,
// ── Filter ──────────────────────────────────────────────────────────────
/// Text currently being typed in the filter input box.
pub filter_input: String,
/// The last successfully applied filter query string.
/// Empty string means no filter is active.
pub filter_active: String,
/// History of previously applied filter queries (most-recent last).
pub filter_history: Vec<String>,
/// When browsing history via Up/Down, the current history index.
/// `None` means the user is not currently browsing history.
pub filter_history_pos: Option<usize>,
// ── Display ─────────────────────────────────────────────────────────────
/// Whether color coding is enabled in transcript views.
///
/// Initialised from the `NO_COLOR` environment variable: `false` when
/// `NO_COLOR` is set and non-empty, `true` otherwise. Can be toggled at
/// runtime with the `c` key.
pub color_enabled: bool,
// ── Lifecycle ───────────────────────────────────────────────────────────
/// Set to `true` to signal the event loop to exit.
pub should_quit: bool,
} }
impl AppState { impl AppState {
/// Return the path to the filter history file: `~/.claude/claudbg.tui.history`.
fn history_file_path() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(PathBuf::from(home).join(".claude").join("claudbg.tui.history"))
}
/// Load filter history lines from disk.
///
/// Returns an empty vec if the file does not exist or cannot be read.
fn load_history() -> Vec<String> {
let Some(path) = Self::history_file_path() else {
return Vec::new();
};
std::fs::read_to_string(&path)
.unwrap_or_default()
.lines()
.filter(|l| !l.trim().is_empty())
.map(str::to_string)
.collect()
}
/// Append a single query to the history file (open in append mode).
///
/// Does nothing if the history file path cannot be determined.
pub fn append_history_to_disk(query: &str) {
let Some(path) = Self::history_file_path() else {
return;
};
// Create parent directory if it doesn't exist.
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{query}");
}
}
/// Create a fresh [`AppState`] ready for the session-list screen. /// Create a fresh [`AppState`] ready for the session-list screen.
/// ///
/// All data fields are initialised to empty / zero so the TUI can render /// All data fields are initialised to empty / zero so the TUI can render
/// immediately; the caller is responsible for populating [`AppState::sessions`] /// immediately; the caller is responsible for populating [`AppState::sessions`]
/// before the first frame is drawn. /// before the first frame is drawn.
pub fn new() -> Self { pub fn new() -> Self {
// Color is on by default; disabled when NO_COLOR is set and non-empty.
let color_enabled = std::env::var("NO_COLOR")
.map(|v| v.is_empty())
.unwrap_or(true);
let filter_history = Self::load_history();
Self { Self {
screen: Screen::SessionList, screen: Screen::SessionList,
sessions: Vec::new(), sessions: Vec::new(),
@ -145,6 +212,12 @@ impl AppState {
focus: Focus::default(), focus: Focus::default(),
show_quit_dialog: false, show_quit_dialog: false,
show_help: false, show_help: false,
filter_input: String::new(),
filter_active: String::new(),
filter_history,
filter_history_pos: None,
color_enabled,
should_quit: false,
} }
} }
@ -333,4 +406,25 @@ mod tests {
assert_eq!(cloned.short_id, item.short_id); assert_eq!(cloned.short_id, item.short_id);
let _ = format!("{item:?}"); let _ = format!("{item:?}");
} }
/// `AppState::new()` enables color when `NO_COLOR` is unset.
#[test]
fn new_color_enabled_when_no_color_unset() {
// Remove NO_COLOR from the environment for this test.
// SAFETY: single-threaded test; no other threads reading the env.
unsafe { std::env::remove_var("NO_COLOR") };
let state = AppState::new();
assert!(state.color_enabled);
}
/// `AppState::new()` disables color when `NO_COLOR` is set to a non-empty value.
#[test]
fn new_color_disabled_when_no_color_set() {
// SAFETY: single-threaded test; no other threads reading the env.
unsafe { std::env::set_var("NO_COLOR", "1") };
let state = AppState::new();
// Restore the environment before asserting so other tests are unaffected.
unsafe { std::env::remove_var("NO_COLOR") };
assert!(!state.color_enabled);
}
} }

Loading…
Cancel
Save