Compare commits

...

17 Commits

Author SHA1 Message Date
Elijah Voigt f8d666f2ad misc updates 2 months ago
Elijah Voigt 7679cfeb36 chore: update beans to completed after toil session
- claudbg-j9az: fix model column blank in sessions list
- claudbg-vqhj: '/' key focuses filter/search bar
- claudbg-8bs3: color-highlight selected agent row
- claudbg-e49f: Home/End keys for transcript navigation
- claudbg-6m2c: token count filter fields (in:/out:/tokens:)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 03caf03a72 feat(claudbg-6m2c): add in/out/tokens filter fields for token-count filtering
Add `in:<N>`, `out:<N>`, and `tokens:<N>` filter fields (with `:`, `>`, `<`
operators) to the TUI filter query language. Populate input_tokens and
output_tokens on SessionListItem from the DB sessions table, wire them into
the SessionRow trait, and update the help modal to document the new fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt ae81563983 feat(claudbg-e49f): add Home/End key bindings to jump to top/bottom of transcript
Home jumps to the top (scroll = 0) and End jumps to the bottom, mirroring
the existing gg/G vim-style bindings. Help modal updated to document both.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 52f33202d3 feat(claudbg-8bs3): highlight selected agent row with REVERSED style instead of '>' cursor
Replace the '>' prefix + yellow fg on the selected/viewing agent row with
Modifier::REVERSED applied to the full span, matching ratatui Table highlight
conventions and removing the need for a cursor character.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 1fd9f8c259 feat(claudbg-vqhj): '/' key focuses filter/search bar
Press '/' to immediately focus the filter input on the session-list
screen and the search input on the transcript screen, matching the
less(1) convention. Updates help modal and placeholder hint text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 7696a5560f fix(claudbg-j9az): populate model from DB in TUI session list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 0940c6f523 chore: ticket TODO.md items as beans, remove already-done item
- claudbg-vqhj: '/' key to focus filter/search bar
- claudbg-j9az: bug — model column always blank in sessions list
- claudbg-6m2c: token count filter fields (in:/out:/tokens: with </> ops)
- claudbg-e49f: Home/End keys to jump to top/bottom of transcript
- claudbg-8bs3: color-highlight selected agent row instead of '>' cursor

Removed "project truncates beginning" TODO item — already implemented
in truncate_project() which prepends '…' and keeps the tail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 0bca469701 fix(claudbg-9627): scan up to 20 lines for cwd field in session discovery
The project path was missing for sessions whose first JSONL line has no
cwd field. Fix reads up to 20 non-empty lines to find any entry with cwd
(typically a system-type entry that may not be at line 1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt f45a0dfd87 feat(claudbg-x45o): sessions list scopes to current project by default
Add --scope=project|user flag to `sessions list` (default: project).
When project scope is active, only sessions whose project_path matches
the current working directory are shown. Use --scope=user to see all.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 0c6ecf1165 feat(claudbg-e4ac): highlight active agent in sidebar when viewing its transcript
When the user is viewing a SubagentTranscript, the corresponding entry
in the sub-agents panel is now highlighted with '>' prefix regardless
of which panel has keyboard focus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 76870fa306 feat(claudbg-mfpd): Esc/Backspace returns to session transcript from agent transcript
When on SubagentTranscript, Esc or Backspace now goes back to the parent
session's Transcript view (lazy-reloads entries) instead of jumping all
the way to the session list. Transcript → session list path unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt a6a0b9a0db feat(claudbg-6t38,claudbg-trk3): add gg/G vim-style jump navigation in transcript
gg (double-g): jump to top of transcript via pending_g chord detection.
G (Shift+G): jump to bottom of transcript.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt ae337dcf94 fix(claudbg-4gtn): clamp Space/PageDown scroll to end of transcript content
Compute total rendered lines on each page-down keypress and cap the
scroll offset so it cannot scroll past the last line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 3b209745fc fix(claudbg-0yk4,claudbg-zniv): populate msg count and project path from DB in TUI
Query the DB cache on TUI startup to enrich SessionListItems with
message_count and fallback project_path for sessions whose JSONL
first line lacks a cwd field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt b7c54dd463 fix(claudbg-u28r): use blue/white search highlight for light mode legibility
Also close claudbg-4g3l, claudbg-ltt0, claudbg-37cj as already implemented.
Fixes pre-existing fmt issues in agents.rs, sessions.rs, filter.rs, and TUI files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt bb50c0c01d chore: convert TODO.md items to beans tickets
Created 10 beans covering all tracked work items:
- claudbg-x45o: sessions list --scope flag (project-scoped by default)
- claudbg-9627: sessions list missing project path bug
- claudbg-zniv: TUI missing project path bug
- claudbg-0yk4: TUI message count always 0
- claudbg-4gtn: Space/PageDown scrolls past transcript end
- claudbg-e4ac: selected agent highlights in agents widget
- claudbg-mfpd: back-navigation from agent to session transcript
- claudbg-u28r: search highlight unreadable in light mode
- claudbg-trk3: Shift+G jump to bottom of transcript
- claudbg-6t38: gg jump to top of transcript

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

@ -0,0 +1,15 @@
---
# claudbg-0yk4
title: 'TUI: session message count always shows 0'
status: completed
type: bug
priority: normal
created_at: 2026-03-31T23:45:03Z
updated_at: 2026-04-01T05:49:51Z
---
In the TUI session list, the message count column always shows 0. The message count is not being populated from the database or computed correctly for the TUI.
## Summary of Changes
In `src/tui/run.rs`: after building the session list from disk discovery, open the DB and query `session_id, COALESCE(project_path, ''), message_count` from the sessions table. Use `tokio::task::block_in_place` to run the async DB query from the synchronous TUI startup. Populate `msg_count` on each `SessionListItem` from the DB result.

@ -1,11 +1,16 @@
---
# claudbg-37cj
title: Add --[no-]color global flag and NO_COLOR env var support
status: todo
status: completed
type: task
priority: normal
created_at: 2026-03-31T00:32:57Z
updated_at: 2026-03-31T00:32:57Z
updated_at: 2026-04-01T05:44:59Z
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).
## Summary of Changes
Already implemented prior to this bean being created. `GlobalOpts` in `src/cli.rs` has `--color`/`--no-color` flags and `color_enabled()` respects `NO_COLOR` env var per spec.

@ -1,10 +1,15 @@
---
# claudbg-4g3l
title: Limit list output to 10 by default with --limit flag
status: todo
status: completed
type: feature
priority: normal
created_at: 2026-03-31T00:32:40Z
updated_at: 2026-03-31T00:32:40Z
updated_at: 2026-04-01T05:44:56Z
---
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.
## Summary of Changes
Already implemented prior to this bean being created. The `Limit` enum with `Count(10)` default is in `src/cli.rs`, applied via `--limit` flag on both `sessions list` and `agents list`.

@ -0,0 +1,15 @@
---
# claudbg-4gtn
title: 'TUI: Space/PageDown scrolls past end of transcript'
status: completed
type: bug
priority: normal
created_at: 2026-03-31T23:45:06Z
updated_at: 2026-04-01T05:51:55Z
---
Space/PageDown in the transcript view continues scrolling past the last line. It should stop at the bottom of the transcript content.
## Summary of Changes
In `src/tui/screens/transcript.rs`, the PageDown/Space handler now clamps scroll to the actual content length: computes `total_lines = build_chat_lines(...).len()` and `max_scroll = total_lines.saturating_sub(page_height)`, then applies `.min(max_scroll)` to prevent scrolling into empty space. Updated 3 existing tests to populate entries before testing scroll behavior.

@ -0,0 +1,14 @@
---
# claudbg-6m2c
title: 'TUI/filter: token count filter fields'
status: completed
type: feature
priority: normal
created_at: 2026-04-01T06:10:58Z
updated_at: 2026-04-01T06:25:42Z
parent: claudbg-2vwx
---
Add token-based filter fields to the filter query language so users can narrow the session list by token usage.\n\nSupported fields and operators:\n- in:<int> — tokens in (input tokens)\n- out:<int> — tokens out (output tokens)\n- tokens:<int> — total tokens (in + out)\n- All three support < and > comparisons (e.g. tokens:>50000, in:<1000)\n\nRequires storing token counts per session in the DB and exposing them via the Filter::matches() path.
Implemented: added in:, out:, and tokens: filter fields. SessionListItem now carries input_tokens/output_tokens populated from DB. SessionRow trait extended. Help modal updated.

@ -0,0 +1,15 @@
---
# claudbg-6t38
title: 'TUI: gg jumps to top of transcript'
status: completed
type: feature
priority: normal
created_at: 2026-03-31T23:45:20Z
updated_at: 2026-04-01T05:53:50Z
---
Add gg (double-g) keybinding in the transcript view to jump to the top of the transcript (vim-style gg navigation). Requires buffering single 'g' keypress to detect the chord.
## Summary of Changes
Added `pending_g: bool` field to `AppState` (init to false). In `handle_transcript_event`, clear `pending_g` on any key that isn't 'g', then handle 'g': if `pending_g` was already set, scroll to top (gg); otherwise set `pending_g = true`.

@ -0,0 +1,17 @@
---
# claudbg-8bs3
title: 'TUI: color-highlight selected agent row instead of ''>'' cursor'
status: completed
type: feature
priority: normal
created_at: 2026-04-01T06:11:10Z
updated_at: 2026-04-01T06:16:40Z
---
In the sub-agents panel (transcript screen), the currently selected/viewed agent is shown with a '>' prefix and yellow text. Instead, use row-level color highlighting (e.g. reversed or bold style on the full row) so the cursor character is not needed — matching how ratatui Table highlights work.\n\nThe rendering is in the subagent_lines iterator in src/tui/screens/transcript.rs. Replace the '> {short_id} {agent_type}' format with ' {short_id} {agent_type}' and apply a highlight style (e.g. Modifier::REVERSED) to the selected row span instead.
## Summary of Changes
Replaced '>' prefix + yellow fg on selected/viewing agent row with Modifier::REVERSED on the full span in the subagent_lines iterator (src/tui/screens/transcript.rs). All rows now use ' {short_id} {agent_type}' text; the highlight is conveyed entirely through the reversed video style.

@ -0,0 +1,15 @@
---
# claudbg-9627
title: 'sessions list: some sessions show no project path'
status: completed
type: bug
priority: normal
created_at: 2026-03-31T23:44:53Z
updated_at: 2026-04-01T05:59:53Z
---
Some entries in `claudbg sessions list` do not display a project path. Investigate why the project path is missing for certain sessions.
## Summary of Changes
Fixed `read_cwd_from_first_line()` in `src/parser/discovery.rs`. The old code stopped after the first non-empty line even if it contained no `cwd` field. The fix scans up to 20 non-empty lines looking for any entry with a `cwd` field. Claude Code typically writes `cwd` on a `system`-type entry which may not be the very first line in all session files.

@ -0,0 +1,11 @@
---
# claudbg-e49f
title: 'TUI: Home/End keys jump to top/bottom of transcript'
status: completed
type: feature
priority: normal
created_at: 2026-04-01T06:11:02Z
updated_at: 2026-04-01T06:19:13Z
---
Add Home and End key bindings in the transcript screen to complement the existing gg/G vim-style bindings:\n- Home → jump to top of transcript (same as gg)\n- End → jump to bottom of transcript (same as G)\n\nHandle in handle_transcript_event() in src/tui/screens/transcript.rs alongside the existing Char('g')/Char('G') cases.

@ -0,0 +1,15 @@
---
# claudbg-e4ac
title: 'TUI: selected agent highlights in agents widget when viewing agent transcript'
status: completed
type: feature
priority: normal
created_at: 2026-03-31T23:45:09Z
updated_at: 2026-04-01T05:55:52Z
---
When an agent transcript is being viewed, the corresponding agent entry in the agents widget panel should be highlighted/selected to show which agent is active.
## Summary of Changes
In `render_transcript` (`src/tui/screens/transcript.rs`), changed the agent highlight condition to check two cases: `is_viewing` (current screen is `SubagentTranscript` with matching `agent_id`) OR `is_panel_selected` (index matches `subagent_selected` and panel has focus). The agent in view is always highlighted regardless of which panel has focus.

@ -0,0 +1,15 @@
---
# claudbg-j9az
title: 'TUI: model column always blank in sessions list'
status: completed
type: bug
priority: normal
created_at: 2026-04-01T06:10:52Z
updated_at: 2026-04-01T06:13:14Z
---
The Model column in the TUI session-list screen is always empty. Root cause: when building SessionListItem in src/tui/run.rs the model field is set to String::new(), and the DB enrichment query (SELECT session_id, project_path, message_count FROM sessions) does not fetch the model column. The model field does exist in the sessions DB table. Fix: add model to the enrichment SELECT and populate item.model during the enrichment loop.
## Summary of Changes
Added model to the DB enrichment query in src/tui/run.rs and populated item.model in the enrichment loop.

@ -1,10 +1,15 @@
---
# claudbg-ltt0
title: 'sessions: no sub-command defaults to sessions list'
status: todo
status: completed
type: feature
priority: normal
created_at: 2026-03-31T00:32:35Z
updated_at: 2026-03-31T00:32:35Z
updated_at: 2026-04-01T05:44:58Z
---
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.
## Summary of Changes
Already implemented prior to this bean being created. `src/main.rs` uses `cmd.unwrap_or(SessionsCmd::List { limit: Default::default(), filter: vec![] })` so `claudbg sessions` defaults to `sessions list`.

@ -0,0 +1,17 @@
---
# claudbg-mfpd
title: 'TUI: navigate back to main session transcript from agent transcript'
status: completed
type: feature
priority: normal
created_at: 2026-03-31T23:45:12Z
updated_at: 2026-04-01T05:55:11Z
---
There is currently no way to navigate back to the main session transcript after drilling into an agent transcript. Add a keybinding (e.g. Backspace or Escape) to return to the parent session view.
## Summary of Changes
Added `go_back_to_transcript(parent_session_id)` to `AppState` in `state.rs`: transitions to `Screen::Transcript { session_id: parent_session_id }`, clears transcript entries (to trigger lazy-reload), and leaves `subagents` intact (they belong to the parent session).
Changed the Esc/Backspace arm in `handle_transcript_event` (`transcript.rs`) to be context-aware: when on `SubagentTranscript`, calls `go_back_to_transcript` to return to the parent session view; when on `Transcript`, calls `go_back` to return to the session list. Backspace is added as a second binding alongside Escape.

@ -0,0 +1,15 @@
---
# claudbg-trk3
title: 'TUI: Shift+G jumps to bottom of transcript'
status: completed
type: feature
priority: normal
created_at: 2026-03-31T23:45:17Z
updated_at: 2026-04-01T05:53:50Z
---
Add Shift+G keybinding in the transcript view to jump to the bottom of the transcript (vim-style G navigation).
## Summary of Changes
Added `KeyCode::Char('G')` arm in `handle_transcript_event`: computes total rendered lines via `build_chat_lines` and sets `transcript_scroll` to `total_lines.saturating_sub(page_height)` — same clamping as PageDown, but jumps directly to bottom.

@ -0,0 +1,15 @@
---
# claudbg-u28r
title: 'TUI: search highlight text unreadable in light mode'
status: completed
type: bug
priority: normal
created_at: 2026-03-31T23:45:15Z
updated_at: 2026-04-01T05:45:54Z
---
In light mode, the selected/highlighted search match text is too dark to read against the highlight background. The search highlight colors need to be adjusted to be legible in both light and dark mode.
## Summary of Changes
Changed `SEARCH_HIGHLIGHT` style in `src/tui/screens/transcript.rs` from `bg(Color::Yellow).fg(Color::Black)` to `bg(Color::Blue).fg(Color::White)`. Blue/white is universally readable in both light and dark terminal themes.

@ -0,0 +1,11 @@
---
# claudbg-vqhj
title: 'TUI: ''/'' key focuses filter/search bar'
status: completed
type: feature
priority: normal
created_at: 2026-04-01T06:10:47Z
updated_at: 2026-04-01T06:15:20Z
---
Pressing '/' in the session list or transcript screen should immediately focus the filter/search input, matching the less(1) convention. Currently 't' and Tab are the only bindings that open the filter. The '/' binding should work on both the session-list screen (opens the filter bar) and the transcript screen (opens the search bar).

@ -0,0 +1,18 @@
---
# claudbg-x45o
title: 'sessions list: scope to current project by default with --scope flag'
status: completed
type: feature
priority: normal
created_at: 2026-03-31T23:44:50Z
updated_at: 2026-04-01T05:58:09Z
---
When run inside a project directory, `claudbg sessions` should only list sessions for that project (similar to how `claude /continue` lists sessions for that project). Override with `--scope=[user|project|local]` — default is 'project'.
## Summary of Changes
Added `SessionScope` enum (`Project` | `User`) to `src/cli.rs`.
Added `--scope` flag to `SessionsCmd::List` with default `project`.
Updated `sessions::list()` signature to accept `scope: SessionScope`.
When scope is `Project`, resolves the current working directory and filters raw rows to only include sessions whose `project_path` exactly matches the CWD. Scope `User` shows all sessions (previous behavior).

@ -0,0 +1,17 @@
---
# claudbg-zniv
title: 'TUI: some sessions show no project path in session list'
status: completed
type: bug
priority: normal
created_at: 2026-03-31T23:44:55Z
updated_at: 2026-04-01T05:49:51Z
blocking:
- claudbg-9627
---
In the TUI session list, some sessions do not display a project path. Likely related to the same root cause as the CLI bug (claudbg-9627).
## Summary of Changes
Implemented together with claudbg-0yk4 in `src/tui/run.rs`. When `sr.project_path` from disk discovery is empty (cwd not present in first JSONL line), fall back to the `project_path` stored in the DB sessions table (which is populated during `claudbg index` sync by scanning all JSONL entries).

1
.gitignore vendored

@ -1,2 +1,3 @@
/target
.direnv/
.claude/worktrees/

@ -1,15 +1,7 @@
# 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
* TUI: `/` should move the selected pane to the "search" bar (similar to less searching) — claudbg-vqhj
* TUI: Model is not showing up in the sessions list... — claudbg-j9az
* TUI: Add the ability to filter by tokens in and tokens out (`in:<int>`, `out:<int>`, `tokens:<int>`, supports < and > too) — claudbg-6m2c
* TUI: In addition to G/gg for navigating to the top/bottom of the transcript, support Home (top) and End (bottom). — claudbg-e49f
* TUI: For the currently selected agent, color code the selected agent instead of the `>` cursor. — claudbg-8bs3

@ -35,11 +35,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1774386573,
"narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
"lastModified": 1774709303,
"narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
"rev": "8110df5ad7abf5d4c0f6fb0f8f978390e77f9685",
"type": "github"
},
"original": {
@ -64,11 +64,11 @@
]
},
"locked": {
"lastModified": 1774581174,
"narHash": "sha256-258qgkMkYPkJ9qpIg63Wk8GoIbVjszkGGPU1wbVHYTk=",
"lastModified": 1774926780,
"narHash": "sha256-JMdDYn0F+swYBILlpCeHDbCSyzqkeSGNxZ/Q5J584jM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "a313afc75b85fc77ac154bf0e62c36f68361fd0b",
"rev": "962a0934d0e32f42d1b5e49186f9595f9b178d2d",
"type": "github"
},
"original": {

@ -91,6 +91,16 @@ impl Limit {
}
}
/// Controls which sessions are shown in `sessions list`.
#[derive(Debug, Clone, Default, clap::ValueEnum)]
pub enum SessionScope {
/// Show only sessions for the current project directory (default).
#[default]
Project,
/// Show all sessions for the current user.
User,
}
/// Top-level CLI entry point for claudbg.
#[derive(Debug, clap::Parser)]
#[command(name = "claudbg", about = "Claude Code session inspector")]
@ -189,6 +199,9 @@ pub enum SessionsCmd {
/// Example: --filter 'model:haiku' --filter 'agents>0'
#[arg(long, action = clap::ArgAction::Append)]
filter: Vec<String>,
/// Scope of sessions to show: 'project' (current dir only, default) or 'user' (all).
#[arg(long, default_value = "project")]
scope: SessionScope,
},
/// Dump raw messages from a session.
Dump {

@ -82,7 +82,11 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
let cap = if opts.verbose { usize::MAX } else { 120 };
let boundary = input_preview.floor_char_boundary(cap);
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 {
""
};
let label = crate::output::color::blue(&format!("[tool: {name}]"), color);
println!("{label} {input_short}{ellipsis}");
}
@ -177,6 +181,12 @@ impl crate::filter::SessionRow for AgentRowRef<'_> {
fn messages(&self) -> u64 {
0
}
fn input_tokens(&self) -> Option<u64> {
None
}
fn output_tokens(&self) -> Option<u64> {
None
}
fn tokens(&self) -> Option<u64> {
None
}
@ -565,7 +575,13 @@ mod tests {
// SAFETY: single-threaded test context; no other threads read HOME concurrently.
unsafe { std::env::set_var("HOME", dir.path()) };
let opts = default_opts();
let result = list("nonexistent-session-id-xyz", crate::cli::Limit::default(), vec![], &opts).await;
let result = list(
"nonexistent-session-id-xyz",
crate::cli::Limit::default(),
vec![],
&opts,
)
.await;
assert!(
matches!(result, Err(crate::error::AppError::NotFound(_))),
"expected NotFound, got: {result:?}"

@ -121,7 +121,11 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
let cap = if opts.verbose { usize::MAX } else { 120 };
let boundary = input_preview.floor_char_boundary(cap);
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 {
""
};
let label = crate::output::color::blue(&format!("[tool: {name}]"), color);
println!("{label} {input_short}{ellipsis}");
}
@ -192,6 +196,12 @@ impl crate::filter::SessionRow for RawSessionRow {
fn messages(&self) -> u64 {
self.message_count as u64
}
fn input_tokens(&self) -> Option<u64> {
None
}
fn output_tokens(&self) -> Option<u64> {
None
}
fn tokens(&self) -> Option<u64> {
None
}
@ -218,6 +228,7 @@ impl crate::filter::SessionRow for RawSessionRow {
pub async fn list(
limit: crate::cli::Limit,
filters: Vec<String>,
scope: crate::cli::SessionScope,
opts: &crate::cli::GlobalOpts,
) -> Result<()> {
// Parse all filter expressions up front so we can report errors immediately.
@ -226,6 +237,14 @@ pub async fn list(
.map(|s| crate::filter::Filter::parse(s))
.collect::<Result<Vec<_>>>()?;
// Resolve project scope: current working directory when scope == Project.
let project_cwd: Option<String> = match scope {
crate::cli::SessionScope::Project => std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().into_owned()),
crate::cli::SessionScope::User => None,
};
let db_path = crate::db::connection::default_db_path();
let db = crate::db::connection::open_db(&db_path, false).await?;
@ -286,6 +305,16 @@ pub async fn list(
});
}
// Apply project scope: only show sessions whose project_path matches CWD.
let raw_rows: Vec<RawSessionRow> = if let Some(ref cwd) = project_cwd {
raw_rows
.into_iter()
.filter(|r| &r.project_path == cwd)
.collect()
} else {
raw_rows
};
// Apply filters (AND semantics: all filters must match).
let raw_rows: Vec<RawSessionRow> = raw_rows
.into_iter()
@ -328,12 +357,10 @@ pub async fn list(
let rows = limit.apply(rows);
let output = match opts.output {
crate::cli::OutputFormat::Table => {
crate::output::render_table(
&["ID", "Date", "Project", "Model", "Messages", "Sub-agents"],
&rows,
)?
}
crate::cli::OutputFormat::Table => crate::output::render_table(
&["ID", "Date", "Project", "Model", "Messages", "Sub-agents"],
&rows,
)?,
crate::cli::OutputFormat::Json => {
let objects: Vec<serde_json::Value> = rows
.iter()
@ -351,7 +378,14 @@ pub async fn list(
crate::output::render_json(&objects)?
}
crate::cli::OutputFormat::Xml => crate::output::render_xml_rows(
&["session_id", "date", "project", "model", "messages", "subagents"],
&[
"session_id",
"date",
"project",
"model",
"messages",
"subagents",
],
&rows,
)?,
};
@ -774,7 +808,13 @@ mod tests {
// SAFETY: single-threaded test context; no other threads read HOME concurrently.
unsafe { std::env::set_var("HOME", dir.path()) };
let opts = default_opts();
let result = list(crate::cli::Limit::default(), vec![], &opts).await;
let result = list(
crate::cli::Limit::default(),
vec![],
crate::cli::SessionScope::User,
&opts,
)
.await;
assert!(result.is_ok(), "list failed: {:?}", result.err());
}
@ -790,7 +830,13 @@ mod tests {
output: OutputFormat::Json,
..default_opts()
};
let result = list(crate::cli::Limit::default(), vec![], &opts).await;
let result = list(
crate::cli::Limit::default(),
vec![],
crate::cli::SessionScope::User,
&opts,
)
.await;
assert!(result.is_ok(), "list json failed: {:?}", result.err());
}
@ -806,7 +852,13 @@ mod tests {
output: OutputFormat::Xml,
..default_opts()
};
let result = list(crate::cli::Limit::default(), vec![], &opts).await;
let result = list(
crate::cli::Limit::default(),
vec![],
crate::cli::SessionScope::User,
&opts,
)
.await;
assert!(result.is_ok(), "list xml failed: {:?}", result.err());
}

@ -20,14 +20,10 @@
//! | `id` | string | `:` (substring) |
//! | `agents` | numeric | `:`, `>`, `<` |
//! | `messages` | numeric | `:`, `>`, `<` |
//! | `in` | numeric | `:`, `>`, `<` |
//! | `out` | 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;
@ -52,9 +48,17 @@ pub trait SessionRow {
fn agents(&self) -> u64;
/// Total message count (all roles).
fn messages(&self) -> u64;
/// Total token count.
/// Input (prompt) token count.
///
/// Returns `None` when the token count is not available for this row type.
fn input_tokens(&self) -> Option<u64>;
/// Output (completion) token count.
///
/// Returns `None` when the token count is not available for this row type.
fn output_tokens(&self) -> Option<u64>;
/// Total token count (input + output).
///
/// Returns `None` when token counts are not available for this row type.
fn tokens(&self) -> Option<u64>;
/// Session date.
///
@ -78,13 +82,20 @@ impl SessionRow for crate::tui::state::SessionListItem {
fn messages(&self) -> u64 {
self.msg_count as u64
}
/// Token count is not yet tracked in `SessionListItem`; always returns `None`.
fn input_tokens(&self) -> Option<u64> {
Some(self.input_tokens)
}
fn output_tokens(&self) -> Option<u64> {
Some(self.output_tokens)
}
fn tokens(&self) -> Option<u64> {
None
Some(self.input_tokens + self.output_tokens)
}
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())
self.date
.get(..10)
.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
}
}
@ -111,6 +122,11 @@ enum Key {
Id,
Agents,
Messages,
/// Input (prompt) token count — `in:`, `in>`, `in<`.
In,
/// Output (completion) token count — `out:`, `out>`, `out<`.
Out,
/// Total token count (input + output) — `tokens:`, `tokens>`, `tokens<`.
Tokens,
Date,
}
@ -123,6 +139,8 @@ impl Key {
"id" => Some(Key::Id),
"agents" => Some(Key::Agents),
"messages" => Some(Key::Messages),
"in" => Some(Key::In),
"out" => Some(Key::Out),
"tokens" => Some(Key::Tokens),
"date" => Some(Key::Date),
_ => None,
@ -212,14 +230,12 @@ fn parse_atom(atom: &str) -> Result<Predicate> {
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"
"unknown filter key '{key_str}' — valid keys: model, project, id, agents, messages, in, out, tokens, date"
))
})?;
// For string keys (model/project/id) only `:` is allowed.
if matches!(key, Key::Model | Key::Project | Key::Id)
&& !matches!(op, Op::Colon)
{
if matches!(key, Key::Model | Key::Project | Key::Id) && !matches!(op, Op::Colon) {
return Err(AppError::Parse(format!(
"key '{key_str}' only supports ':' operator"
)));
@ -396,6 +412,14 @@ fn eval_pred<R: SessionRow>(pred: &Predicate, row: &R) -> bool {
// ── Numeric keys ────────────────────────────────────────────────────
Key::Agents => eval_numeric(row.agents(), &pred.op, &pred.value),
Key::Messages => eval_numeric(row.messages(), &pred.op, &pred.value),
Key::In => match row.input_tokens() {
Some(v) => eval_numeric(v, &pred.op, &pred.value),
None => false,
},
Key::Out => match row.output_tokens() {
Some(v) => eval_numeric(v, &pred.op, &pred.value),
None => false,
},
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.
@ -479,6 +503,23 @@ mod tests {
model: model.to_string(),
msg_count,
agent_count,
input_tokens: 0,
output_tokens: 0,
}
}
/// Build a row with the given token counts; all other fields use fixed defaults.
fn make_row_with_tokens(full_id: &str, input_tokens: u64, output_tokens: u64) -> SessionListItem {
SessionListItem {
short_id: full_id.get(..8).unwrap_or(full_id).to_string(),
full_id: full_id.to_string(),
date: "2026-03-30 10:00:00".to_string(),
project: "/proj".to_string(),
model: "model".to_string(),
msg_count: 5,
agent_count: 0,
input_tokens,
output_tokens,
}
}
@ -622,29 +663,64 @@ mod tests {
#[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");
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");
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");
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");
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));
}
@ -652,14 +728,28 @@ mod tests {
#[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");
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");
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));
}
@ -668,8 +758,22 @@ mod tests {
#[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");
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));
}
@ -677,8 +781,22 @@ mod tests {
#[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");
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));
}
@ -686,8 +804,22 @@ mod tests {
#[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");
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));
}
@ -697,8 +829,22 @@ mod tests {
#[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");
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));
}
@ -706,8 +852,22 @@ mod tests {
#[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");
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));
}
@ -715,8 +875,22 @@ mod tests {
#[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");
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));
}
@ -724,8 +898,22 @@ mod tests {
#[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");
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));
}
@ -735,9 +923,30 @@ mod tests {
#[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");
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));
@ -746,9 +955,30 @@ mod tests {
#[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");
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));
@ -759,23 +989,106 @@ mod tests {
// "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");
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");
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");
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 ─────────────────────────────────────────────────
// ── Token filter fields ──────────────────────────────────────────────────
#[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));
fn tokens_gt_matches_when_total_exceeds_threshold() {
let f = Filter::parse("tokens>50000").unwrap();
let big = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 40000, 20000);
let small = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 1000, 500);
assert!(f.matches(&big)); // 60000 > 50000
assert!(!f.matches(&small)); // 1500 is not > 50000
}
#[test]
fn tokens_lt_matches_when_total_below_threshold() {
let f = Filter::parse("tokens<10000").unwrap();
let small = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 3000, 2000);
let big = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 20000, 5000);
assert!(f.matches(&small)); // 5000 < 10000
assert!(!f.matches(&big)); // 25000 is not < 10000
}
#[test]
fn tokens_colon_equality() {
let f = Filter::parse("tokens:1000").unwrap();
let exact = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 700, 300);
let other = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 700, 400);
assert!(f.matches(&exact)); // 700+300 == 1000
assert!(!f.matches(&other)); // 1100 != 1000
}
#[test]
fn in_gt_matches_input_tokens() {
let f = Filter::parse("in>5000").unwrap();
let high = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 10000, 1000);
let low = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 500, 1000);
assert!(f.matches(&high)); // 10000 > 5000
assert!(!f.matches(&low)); // 500 is not > 5000
}
#[test]
fn out_lt_matches_output_tokens() {
let f = Filter::parse("out<1000").unwrap();
let low_out = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 5000, 200);
let high_out = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 5000, 5000);
assert!(f.matches(&low_out)); // 200 < 1000
assert!(!f.matches(&high_out)); // 5000 is not < 1000
}
#[test]
fn in_and_out_combined() {
// Large input AND small output — very "reading-heavy" sessions.
let f = Filter::parse("in>10000 AND out<500").unwrap();
let reading_heavy = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 50000, 200);
let balanced = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 50000, 2000);
assert!(f.matches(&reading_heavy));
assert!(!f.matches(&balanced));
}
#[test]
fn tokens_zero_default_with_colon_zero() {
// make_row defaults tokens to 0; tokens:0 should match.
let f = Filter::parse("tokens:0").unwrap();
let row = make_row(
"model",
"/proj",
"aaaaaaaa-0000-0000-0000-000000000000",
5,
0,
"2026-03-30 10:00:00",
);
assert!(f.matches(&row));
}
}

@ -1,15 +1,23 @@
//! claudbg binary entry point.
use clap::Parser;
use claudbg::cli::{AgentsCmd, Cli, Commands, SessionsCmd};
use claudbg::cli::{AgentsCmd, Cli, Commands, SessionScope, SessionsCmd};
use claudbg::error::Result;
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Sessions { cmd } => match cmd.unwrap_or(SessionsCmd::List { limit: Default::default(), filter: vec![] }) {
SessionsCmd::List { limit, filter } => claudbg::commands::sessions::list(limit, filter, &cli.global).await?,
Commands::Sessions { cmd } => match cmd.unwrap_or(SessionsCmd::List {
limit: Default::default(),
filter: vec![],
scope: SessionScope::Project,
}) {
SessionsCmd::List {
limit,
filter,
scope,
} => claudbg::commands::sessions::list(limit, filter, scope, &cli.global).await?,
SessionsCmd::Dump { id, follow } => {
claudbg::commands::sessions::dump(&id, follow, &cli.global).await?
}
@ -18,9 +26,11 @@ async fn main() -> Result<()> {
}
},
Commands::Agents { cmd } => match cmd {
AgentsCmd::List { session_id, limit, filter } => {
claudbg::commands::agents::list(&session_id, limit, filter, &cli.global).await?
}
AgentsCmd::List {
session_id,
limit,
filter,
} => claudbg::commands::agents::list(&session_id, limit, filter, &cli.global).await?,
AgentsCmd::Dump {
session_id,
agent_id,

@ -44,25 +44,35 @@ fn claude_projects_dir() -> Option<PathBuf> {
Some(PathBuf::from(home).join(".claude").join("projects"))
}
/// Read the first non-empty line of a file and attempt to extract `cwd` from it.
/// Scan the first few lines of a session file to find a `cwd` field.
///
/// Returns `None` if the file cannot be read or the first line cannot be parsed.
/// Reads up to 20 non-empty lines looking for any entry whose top-level `cwd`
/// field is set. The `cwd` field is written by Claude Code on `system`-type
/// entries; it may not appear on the very first line if the session file starts
/// with a user or assistant message.
///
/// Returns `None` if the file cannot be read or no `cwd` is found within the
/// first 20 lines.
fn read_cwd_from_first_line(path: &Path) -> Option<String> {
const MAX_LINES: usize = 20;
let file = std::fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let mut checked = 0usize;
for line in reader.lines() {
let line = line.ok()?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
checked += 1;
if let Ok(entry) = serde_json::from_str::<RawEntry>(trimmed)
&& entry.cwd.is_some()
{
return entry.cwd;
}
// Return after first non-empty line regardless.
break;
if checked >= MAX_LINES {
break;
}
}
None
}
@ -266,11 +276,7 @@ pub fn discover_all_agents() -> crate::error::Result<Vec<AgentRef>> {
let session_dir_entries = match std::fs::read_dir(&proj_path) {
Ok(e) => e,
Err(err) => {
eprintln!(
"claudbg: could not read {}: {}",
proj_path.display(),
err
);
eprintln!("claudbg: could not read {}: {}", proj_path.display(), err);
continue;
}
};

@ -15,8 +15,8 @@ use crate::tui::state::AppState;
// ---------------------------------------------------------------------------
/// Dialog dimensions.
const DIALOG_WIDTH: u16 = 32;
const DIALOG_HEIGHT: u16 = 20;
const DIALOG_WIDTH: u16 = 36;
const DIALOG_HEIGHT: u16 = 36;
/// Compute a centered [`Rect`] of the given size within `area`.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
@ -40,12 +40,30 @@ const HELP_TEXT: &str = "\
Spc/PgDn page down\n\
\u{21e7}Spc/PgUp page up\n\
\u{2190}/\u{2192} h/l scroll lr\n\
Home/gg jump to top\n\
End/G jump to bottom\n\
Tab cycle panes\n\
Enter open/select\n\
Esc go back\n\
\n\
Filter (sessions)\n\
t / / open filter\n\
Enter apply & close\n\
Esc clear input\n\
\n\
Filter fields\n\
model:haiku substring\n\
project:foo substring\n\
id:abc substring\n\
agents>0 numeric\n\
messages<50 numeric\n\
in>5000 input tokens\n\
out<1000 output tokens\n\
tokens>50000 total tokens\n\
date>2026-01 date\n\
\n\
Search (transcript)\n\
t open search\n\
t / / open search\n\
n / N next/prev match\n\
Enter apply & close\n\
Esc clear & close\n\

@ -41,9 +41,7 @@ pub fn render_quit_dialog(f: &mut Frame, area: Rect) {
// Clear the background behind the dialog.
f.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" Quit? ")
.borders(Borders::ALL);
let block = Block::default().title(" Quit? ").borders(Borders::ALL);
let paragraph = Paragraph::new(" q = yes Esc = no ")
.block(block)

@ -9,16 +9,17 @@
//! 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::collections::HashMap;
use std::io::{self, Stdout};
use std::time::Duration;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
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};
@ -218,10 +219,80 @@ pub fn run_tui() -> Result<()> {
model: String::new(),
msg_count: 0,
agent_count,
input_tokens: 0,
output_tokens: 0,
}
})
.collect();
// Enrich session list with data from the DB cache (message counts,
// token counts, and fallback project paths for sessions whose JSONL
// first line has no cwd).
// Best-effort: silently skip if the DB is missing or unreadable.
let db_enrichment: HashMap<String, (String, usize, String, u64, u64)> =
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let db_path = crate::db::connection::default_db_path();
let db = match crate::db::connection::open_db(&db_path, false).await {
Ok(d) => d,
Err(_) => return HashMap::new(),
};
let conn = match db.connect() {
Ok(c) => c,
Err(_) => return HashMap::new(),
};
let mut rows = match conn
.query(
"SELECT session_id, COALESCE(project_path, ''), message_count, \
COALESCE(model, ''), input_tokens, output_tokens FROM sessions",
(),
)
.await
{
Ok(r) => r,
Err(_) => return HashMap::new(),
};
let mut map: HashMap<String, (String, usize, String, u64, u64)> = HashMap::new();
while let Ok(Some(row)) = rows.next().await {
let sid: String = match row.get(0) {
Ok(v) => v,
Err(_) => continue,
};
let project: String = row.get(1).unwrap_or_default();
let count: i64 = row.get(2).unwrap_or(0);
let model: String = row.get(3).unwrap_or_default();
let input_tokens: i64 = row.get(4).unwrap_or(0);
let output_tokens: i64 = row.get(5).unwrap_or(0);
map.insert(
sid,
(
project,
count as usize,
model,
input_tokens.max(0) as u64,
output_tokens.max(0) as u64,
),
);
}
map
})
});
for item in &mut state.sessions {
if let Some((db_project, db_count, db_model, db_input, db_output)) =
db_enrichment.get(&item.full_id)
{
if item.project.is_empty() && !db_project.is_empty() {
item.project = db_project.clone();
}
item.msg_count = *db_count;
if item.model.is_empty() && !db_model.is_empty() {
item.model = db_model.clone();
}
item.input_tokens = *db_input;
item.output_tokens = *db_output;
}
}
loop {
// Load transcript data lazily when entering a transcript screen.
maybe_load_transcript(&mut state);

@ -26,7 +26,11 @@ fn truncate_project(path: &str, max_chars: usize) -> String {
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<_>>()
let keep: String = path
.chars()
.rev()
.take(max_chars - 1)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
@ -58,7 +62,7 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
let widths = [
Constraint::Length(8),
Constraint::Length(20),
Constraint::Min(10), // project — gets remaining space
Constraint::Min(10), // project — gets remaining space
Constraint::Length(20),
Constraint::Length(6),
Constraint::Length(7),
@ -72,7 +76,7 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
// 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
.saturating_sub(68) // subtract fixed columns + separators + borders
.max(10) as usize;
let project_display_max = project_max + 30; // generous — actual render clips
@ -110,9 +114,7 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
format!(" Sessions [filter: {}] ", state.filter_active)
};
let block = Block::default()
.title(table_title)
.borders(Borders::ALL);
let block = Block::default().title(table_title).borders(Borders::ALL);
let highlight_style = Style::default().add_modifier(Modifier::REVERSED);
@ -180,7 +182,7 @@ fn render_filter_bar(f: &mut Frame, area: Rect, state: &AppState) {
Line::from(vec![
label,
Span::styled(
"Press 't' or Tab to focus — type a query and press Enter",
"Press '/', 't', or Tab to focus — type a query and press Enter",
Style::default().fg(Color::DarkGray),
),
])
@ -242,8 +244,7 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
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);
state.list_selected = (state.list_selected + 1).min(visible.len() - 1);
}
true
}
@ -258,7 +259,7 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
true
}
// Focus the filter input directly.
KeyCode::Char('t') => {
KeyCode::Char('t') | KeyCode::Char('/') => {
state.focus = Focus::FilterInput;
true
}
@ -303,7 +304,11 @@ fn handle_filter_input_event(code: KeyCode, state: &mut AppState) -> bool {
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);
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);
@ -381,6 +386,8 @@ mod tests {
model: "claude-sonnet-4-6".to_string(),
msg_count: 10,
agent_count: 2,
input_tokens: 0,
output_tokens: 0,
}
}
@ -428,8 +435,14 @@ mod tests {
#[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.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);
@ -439,7 +452,10 @@ mod tests {
#[test]
fn down_clamps_at_end() {
let mut state = AppState::new();
state.sessions.push(make_item("aaaaaaaa", "aaaaaaaa-0000-0000-0000-000000000000"));
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).
@ -449,8 +465,14 @@ mod tests {
#[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.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);
@ -468,8 +490,14 @@ mod tests {
#[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.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);
@ -478,8 +506,14 @@ mod tests {
#[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.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);

@ -169,11 +169,13 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
}
}
ContentBlock::ToolUse { name, input, .. } => {
let input_str =
serde_json::to_string(input).unwrap_or_default();
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 ellipsis = if input_str.len() > TOOL_INPUT_TRUNCATE {
"…"
} else {
""
};
let tool_style = if color_enabled {
Style::default().fg(Color::Cyan)
} else {
@ -187,20 +189,23 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
ContentBlock::ToolResult {
content, is_error, ..
} => {
let err_flag =
if is_error.unwrap_or(false) { " (error)" } else { "" };
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()
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 ellipsis = if preview.len() > TOOL_RESULT_TRUNCATE {
"…"
} else {
""
};
let style = if color_enabled {
if is_error.unwrap_or(false) {
Style::default().fg(Color::Red)
@ -221,10 +226,7 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
} else {
Style::default()
};
lines.push(Line::from(Span::styled(
"[image]".to_string(),
img_style,
)));
lines.push(Line::from(Span::styled("[image]".to_string(), img_style)));
}
ContentBlock::Unknown => {
lines.push(Line::from("[unknown block]".to_string()));
@ -243,7 +245,7 @@ pub fn build_chat_lines(entries: &[RawEntry], color_enabled: bool) -> Vec<Line<'
// ---------------------------------------------------------------------------
/// Style applied to matched substrings in search mode.
const SEARCH_HIGHLIGHT: Style = Style::new().bg(Color::Yellow).fg(Color::Black);
const SEARCH_HIGHLIGHT: Style = Style::new().bg(Color::Blue).fg(Color::White);
/// Split a single [`Span`] around all case-insensitive occurrences of `query_lower`,
/// returning a vec of spans where matches are styled with [`SEARCH_HIGHLIGHT`].
@ -412,9 +414,9 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
// Split area: header (fixed) | body (min) | search bar (fixed).
let chunks = Layout::vertical([
Constraint::Length(4), // stats header
Constraint::Min(1), // chat log + sub-agents
Constraint::Length(3), // search bar
Constraint::Length(4), // stats header
Constraint::Min(1), // chat log + sub-agents
Constraint::Length(3), // search bar
])
.split(area);
@ -461,7 +463,10 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
let chat_paragraph = Paragraph::new(Text::from(chat_lines))
.block(chat_block)
.scroll((state.transcript_scroll as u16, state.transcript_h_scroll as u16));
.scroll((
state.transcript_scroll as u16,
state.transcript_h_scroll as u16,
));
f.render_widget(chat_paragraph, body_chunks[0]);
@ -490,25 +495,30 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
} 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 {
let agent_type = agent.agent_type.as_deref().unwrap_or("agent").to_string();
// Highlight when this agent is the one currently being viewed
// (SubagentTranscript) OR when it is selected in the panel.
let is_viewing = matches!(
&state.screen,
crate::tui::state::Screen::SubagentTranscript { agent_id, .. }
if *agent_id == agent.agent_id
);
let is_panel_selected =
i == state.subagent_selected && state.focus == Focus::SubagentsPanel;
let text = format!(" {short_id} {agent_type}");
if is_viewing || is_panel_selected {
Line::from(Span::styled(
format!("> {short_id} {agent_type}"),
Style::default().fg(Color::Yellow),
text,
Style::default().add_modifier(Modifier::REVERSED),
))
} else {
Line::from(format!(" {short_id} {agent_type}"))
Line::from(text)
}
})
.collect()
};
let subagents_paragraph = Paragraph::new(Text::from(subagent_lines))
.block(subagents_block);
let subagents_paragraph = Paragraph::new(Text::from(subagent_lines)).block(subagents_block);
f.render_widget(subagents_paragraph, body_chunks[1]);
@ -536,6 +546,11 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
return handle_search_input_event(key.code, state);
}
// Clear the pending-g chord state on any key that is not 'g' itself.
if !matches!(key.code, KeyCode::Char('g')) {
state.pending_g = false;
}
match key.code {
// Toggle focus: ChatLog → SubagentsPanel → SearchInput → ChatLog.
KeyCode::Tab => {
@ -546,9 +561,17 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
};
true
}
// Navigate back to the session list.
KeyCode::Esc => {
state.go_back();
// Navigate back: from SubagentTranscript → parent session transcript;
// from Transcript → session list.
KeyCode::Esc | KeyCode::Backspace => {
if let crate::tui::state::Screen::SubagentTranscript {
parent_session_id, ..
} = state.screen.clone()
{
state.go_back_to_transcript(&parent_session_id);
} else {
state.go_back();
}
true
}
// Show quit dialog (don't exit immediately on transcript screen).
@ -562,7 +585,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
true
}
// Jump directly to the search input.
KeyCode::Char('t') => {
KeyCode::Char('t') | KeyCode::Char('/') => {
state.focus = Focus::SearchInput;
true
}
@ -571,8 +594,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
if !state.search_match_lines.is_empty() {
state.search_current_match =
(state.search_current_match + 1) % state.search_match_lines.len();
state.transcript_scroll =
state.search_match_lines[state.search_current_match];
state.transcript_scroll = state.search_match_lines[state.search_current_match];
}
true
}
@ -583,8 +605,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
.search_current_match
.checked_sub(1)
.unwrap_or(state.search_match_lines.len() - 1);
state.transcript_scroll =
state.search_match_lines[state.search_current_match];
state.transcript_scroll = state.search_match_lines[state.search_current_match];
}
true
}
@ -603,8 +624,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
Focus::SubagentsPanel => {
if !state.subagents.is_empty() {
let max = state.subagents.len() - 1;
state.subagent_selected =
(state.subagent_selected + 1).min(max);
state.subagent_selected = (state.subagent_selected + 1).min(max);
}
true
}
@ -627,7 +647,10 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
}
KeyCode::PageDown | KeyCode::Char(' ') => {
let page = (state.transcript_page_height as usize).max(1);
state.transcript_scroll = state.transcript_scroll.saturating_add(page);
let total_lines =
build_chat_lines(&state.transcript_entries, state.color_enabled).len();
let max_scroll = total_lines.saturating_sub(state.transcript_page_height as usize);
state.transcript_scroll = state.transcript_scroll.saturating_add(page).min(max_scroll);
true
}
// Horizontal scroll — only meaningful in ChatLog.
@ -655,6 +678,36 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
false
}
}
// Home → jump to top of transcript.
KeyCode::Home => {
state.transcript_scroll = 0;
true
}
// End → jump to bottom of transcript.
KeyCode::End => {
let total_lines =
build_chat_lines(&state.transcript_entries, state.color_enabled).len();
state.transcript_scroll =
total_lines.saturating_sub(state.transcript_page_height as usize);
true
}
// gg → jump to top of transcript (vim-style).
KeyCode::Char('g') => {
if std::mem::take(&mut state.pending_g) {
state.transcript_scroll = 0;
} else {
state.pending_g = true;
}
true
}
// Shift+G → jump to bottom of transcript (vim-style).
KeyCode::Char('G') => {
let total_lines =
build_chat_lines(&state.transcript_entries, state.color_enabled).len();
state.transcript_scroll =
total_lines.saturating_sub(state.transcript_page_height as usize);
true
}
_ => false,
}
}
@ -724,16 +777,14 @@ pub fn load_transcript_for_session(session_id: &str, state: &mut AppState) {
};
// 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 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)
{
if let Ok(agents) = crate::parser::discovery::discover_agents_for_session(&sr.file_path) {
state.subagents = agents;
}
@ -755,21 +806,16 @@ pub fn load_transcript_for_session(session_id: &str, state: &mut AppState) {
///
/// 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,
) {
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 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 };
@ -905,11 +951,7 @@ mod tests {
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();
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"));
}
@ -930,11 +972,7 @@ mod tests {
});
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();
let tools_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(tools_text.contains("Bash"));
}
@ -1215,6 +1253,10 @@ mod tests {
#[test]
fn space_scrolls_down_by_page() {
let mut state = transcript_state();
// Populate enough entries that clamping won't prevent scrolling by the full page.
state.transcript_entries = (0..100)
.map(|i| user_text_entry(&format!("line {i}")))
.collect();
state.transcript_page_height = 20;
handle_transcript_event(press(KeyCode::Char(' ')), &mut state);
assert_eq!(state.transcript_scroll, 20);
@ -1223,6 +1265,10 @@ mod tests {
#[test]
fn pagedown_scrolls_down_by_page() {
let mut state = transcript_state();
// Populate enough entries that clamping won't prevent scrolling by the full page.
state.transcript_entries = (0..100)
.map(|i| user_text_entry(&format!("line {i}")))
.collect();
state.transcript_page_height = 15;
handle_transcript_event(press(KeyCode::PageDown), &mut state);
assert_eq!(state.transcript_scroll, 15);
@ -1410,6 +1456,8 @@ mod tests {
#[test]
fn page_scroll_uses_one_when_height_is_zero() {
let mut state = transcript_state();
// Need at least one line of content so clamping allows scroll=1.
state.transcript_entries = vec![user_text_entry("hello")];
state.transcript_page_height = 0;
handle_transcript_event(press(KeyCode::Char(' ')), &mut state);
assert_eq!(state.transcript_scroll, 1);

@ -38,6 +38,12 @@ pub struct SessionListItem {
pub msg_count: usize,
/// Number of sub-agent runs attached to this session.
pub agent_count: usize,
/// Total input (prompt) tokens consumed by the session.
/// Zero when not yet fetched from the DB.
pub input_tokens: u64,
/// Total output (completion) tokens produced by the session.
/// Zero when not yet fetched from the DB.
pub output_tokens: u64,
}
// ---------------------------------------------------------------------------
@ -157,6 +163,11 @@ pub struct AppState {
/// `None` means the user is not currently browsing history.
pub filter_history_pos: Option<usize>,
// ── Keyboard chord state ─────────────────────────────────────────────────
/// Tracks whether a leading `g` keypress was seen, awaiting a second `g`
/// to complete the `gg` (jump-to-top) chord. Cleared on any other key.
pub pending_g: bool,
// ── Display ─────────────────────────────────────────────────────────────
/// Whether color coding is enabled in transcript views.
///
@ -174,7 +185,11 @@ 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"))
Some(
PathBuf::from(home)
.join(".claude")
.join("claudbg.tui.history"),
)
}
/// Load filter history lines from disk.
@ -240,6 +255,7 @@ impl AppState {
filter_active: String::new(),
filter_history,
filter_history_pos: None,
pending_g: false,
color_enabled,
should_quit: false,
}
@ -290,6 +306,27 @@ impl AppState {
self.focus = Focus::ChatLog;
}
/// Return to the parent session's transcript from a sub-agent transcript.
///
/// Clears transcript entries so the lazy-loader will reload the parent
/// session's content. The sub-agents list is left intact because it
/// belongs to the parent session and will still be accurate.
pub fn go_back_to_transcript(&mut self, parent_session_id: &str) {
self.screen = Screen::Transcript {
session_id: parent_session_id.to_string(),
};
self.transcript_entries.clear();
self.transcript_scroll = 0;
self.transcript_h_scroll = 0;
self.search_input.clear();
self.search_active.clear();
self.search_match_lines.clear();
self.search_current_match = 0;
self.pending_g = false;
self.focus = Focus::ChatLog;
// Leave subagents intact — they belong to the parent session.
}
/// Return to the session-list screen.
///
/// Clears transcript and sub-agent data. The session list and the
@ -437,6 +474,8 @@ mod tests {
model: "claude-sonnet-4-6".to_string(),
msg_count: 10,
agent_count: 2,
input_tokens: 0,
output_tokens: 0,
};
let cloned = item.clone();
assert_eq!(cloned.short_id, item.short_id);

Loading…
Cancel
Save