Compare commits

..

No commits in common. 'f8d666f2ad1a39d95e566ea3468a1aa59091e677' and '3ba8cf0e077014f4c6b2622bb532cea10bac71b0' have entirely different histories.

@ -1,15 +0,0 @@
---
# 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,16 +1,11 @@
---
# claudbg-37cj
title: Add --[no-]color global flag and NO_COLOR env var support
status: completed
status: todo
type: task
priority: normal
created_at: 2026-03-31T00:32:57Z
updated_at: 2026-04-01T05:44:59Z
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).
## 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,15 +1,10 @@
---
# claudbg-4g3l
title: Limit list output to 10 by default with --limit flag
status: completed
status: todo
type: feature
priority: normal
created_at: 2026-03-31T00:32:40Z
updated_at: 2026-04-01T05:44:56Z
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.
## 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`.

@ -1,15 +0,0 @@
---
# 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.

@ -1,14 +0,0 @@
---
# 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.

@ -1,15 +0,0 @@
---
# 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`.

@ -1,17 +0,0 @@
---
# 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.

@ -1,15 +0,0 @@
---
# 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.

@ -1,11 +0,0 @@
---
# 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.

@ -1,15 +0,0 @@
---
# 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.

@ -1,15 +0,0 @@
---
# 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,15 +1,10 @@
---
# claudbg-ltt0
title: 'sessions: no sub-command defaults to sessions list'
status: completed
status: todo
type: feature
priority: normal
created_at: 2026-03-31T00:32:35Z
updated_at: 2026-04-01T05:44:58Z
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.
## 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`.

@ -1,17 +0,0 @@
---
# 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.

@ -1,15 +0,0 @@
---
# 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.

@ -1,15 +0,0 @@
---
# 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.

@ -1,11 +0,0 @@
---
# 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).

@ -1,18 +0,0 @@
---
# 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).

@ -1,17 +0,0 @@
---
# 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,3 +1,2 @@
/target
.direnv/
.claude/worktrees/

@ -1,7 +1,15 @@
# TODO
* 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
* 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

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

@ -91,16 +91,6 @@ 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")]
@ -199,9 +189,6 @@ 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,11 +82,7 @@ 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}");
}
@ -181,12 +177,6 @@ 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
}
@ -575,13 +565,7 @@ 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,11 +121,7 @@ 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}");
}
@ -196,12 +192,6 @@ 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
}
@ -228,7 +218,6 @@ 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.
@ -237,14 +226,6 @@ 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?;
@ -305,16 +286,6 @@ 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()
@ -357,10 +328,12 @@ 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()
@ -378,14 +351,7 @@ 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,
)?,
};
@ -808,13 +774,7 @@ 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![],
crate::cli::SessionScope::User,
&opts,
)
.await;
let result = list(crate::cli::Limit::default(), vec![], &opts).await;
assert!(result.is_ok(), "list failed: {:?}", result.err());
}
@ -830,13 +790,7 @@ mod tests {
output: OutputFormat::Json,
..default_opts()
};
let result = list(
crate::cli::Limit::default(),
vec![],
crate::cli::SessionScope::User,
&opts,
)
.await;
let result = list(crate::cli::Limit::default(), vec![], &opts).await;
assert!(result.is_ok(), "list json failed: {:?}", result.err());
}
@ -852,13 +806,7 @@ mod tests {
output: OutputFormat::Xml,
..default_opts()
};
let result = list(
crate::cli::Limit::default(),
vec![],
crate::cli::SessionScope::User,
&opts,
)
.await;
let result = list(crate::cli::Limit::default(), vec![], &opts).await;
assert!(result.is_ok(), "list xml failed: {:?}", result.err());
}

@ -20,10 +20,14 @@
//! | `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;
@ -48,17 +52,9 @@ pub trait SessionRow {
fn agents(&self) -> u64;
/// Total message count (all roles).
fn messages(&self) -> u64;
/// Input (prompt) token count.
/// Total 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.
///
@ -82,20 +78,13 @@ impl SessionRow for crate::tui::state::SessionListItem {
fn messages(&self) -> u64 {
self.msg_count as u64
}
fn input_tokens(&self) -> Option<u64> {
Some(self.input_tokens)
}
fn output_tokens(&self) -> Option<u64> {
Some(self.output_tokens)
}
/// Token count is not yet tracked in `SessionListItem`; always returns `None`.
fn tokens(&self) -> Option<u64> {
Some(self.input_tokens + self.output_tokens)
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())
self.date.get(..10).and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
}
}
@ -122,11 +111,6 @@ 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,
}
@ -139,8 +123,6 @@ 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,
@ -230,12 +212,14 @@ 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, in, out, tokens, date"
"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) {
if matches!(key, Key::Model | Key::Project | Key::Id)
&& !matches!(op, Op::Colon)
{
return Err(AppError::Parse(format!(
"key '{key_str}' only supports ':' operator"
)));
@ -412,14 +396,6 @@ 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.
@ -503,23 +479,6 @@ 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,
}
}
@ -663,64 +622,29 @@ 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));
}
@ -728,28 +652,14 @@ 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));
}
@ -758,22 +668,8 @@ 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));
}
@ -781,22 +677,8 @@ 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));
}
@ -804,22 +686,8 @@ 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));
}
@ -829,22 +697,8 @@ 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));
}
@ -852,22 +706,8 @@ 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));
}
@ -875,22 +715,8 @@ 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));
}
@ -898,22 +724,8 @@ 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));
}
@ -923,30 +735,9 @@ 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));
@ -955,30 +746,9 @@ 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));
@ -989,106 +759,23 @@ 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));
}
// ── Token filter fields ──────────────────────────────────────────────────
// ── Tokens not available ─────────────────────────────────────────────────
#[test]
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));
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));
}
}

@ -1,23 +1,15 @@
//! claudbg binary entry point.
use clap::Parser;
use claudbg::cli::{AgentsCmd, Cli, Commands, SessionScope, SessionsCmd};
use claudbg::cli::{AgentsCmd, Cli, Commands, 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![],
scope: SessionScope::Project,
}) {
SessionsCmd::List {
limit,
filter,
scope,
} => claudbg::commands::sessions::list(limit, filter, scope, &cli.global).await?,
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?,
SessionsCmd::Dump { id, follow } => {
claudbg::commands::sessions::dump(&id, follow, &cli.global).await?
}
@ -26,11 +18,9 @@ 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,35 +44,25 @@ fn claude_projects_dir() -> Option<PathBuf> {
Some(PathBuf::from(home).join(".claude").join("projects"))
}
/// Scan the first few lines of a session file to find a `cwd` field.
/// Read the first non-empty line of a file and attempt to extract `cwd` from it.
///
/// 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.
/// Returns `None` if the file cannot be read or the first line cannot be parsed.
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;
}
if checked >= MAX_LINES {
break;
}
// Return after first non-empty line regardless.
break;
}
None
}
@ -276,7 +266,11 @@ 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 = 36;
const DIALOG_HEIGHT: u16 = 36;
const DIALOG_WIDTH: u16 = 32;
const DIALOG_HEIGHT: u16 = 20;
/// Compute a centered [`Rect`] of the given size within `area`.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
@ -40,30 +40,12 @@ 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,7 +41,9 @@ 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,17 +9,16 @@
//! 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};
@ -219,80 +218,10 @@ 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,11 +26,7 @@ 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();
@ -62,7 +58,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),
@ -76,7 +72,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
@ -114,7 +110,9 @@ 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);
@ -182,7 +180,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),
),
])
@ -244,7 +242,8 @@ 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
}
@ -259,7 +258,7 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
true
}
// Focus the filter input directly.
KeyCode::Char('t') | KeyCode::Char('/') => {
KeyCode::Char('t') => {
state.focus = Focus::FilterInput;
true
}
@ -304,11 +303,7 @@ 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);
@ -386,8 +381,6 @@ mod tests {
model: "claude-sonnet-4-6".to_string(),
msg_count: 10,
agent_count: 2,
input_tokens: 0,
output_tokens: 0,
}
}
@ -435,14 +428,8 @@ 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);
@ -452,10 +439,7 @@ 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).
@ -465,14 +449,8 @@ 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);
@ -490,14 +468,8 @@ 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);
@ -506,14 +478,8 @@ 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,13 +169,11 @@ 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 {
@ -189,23 +187,20 @@ 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)
@ -226,7 +221,10 @@ 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()));
@ -245,7 +243,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::Blue).fg(Color::White);
const SEARCH_HIGHLIGHT: Style = Style::new().bg(Color::Yellow).fg(Color::Black);
/// Split a single [`Span`] around all case-insensitive occurrences of `query_lower`,
/// returning a vec of spans where matches are styled with [`SEARCH_HIGHLIGHT`].
@ -414,9 +412,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);
@ -463,10 +461,7 @@ 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]);
@ -495,30 +490,25 @@ 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();
// 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 {
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(
text,
Style::default().add_modifier(Modifier::REVERSED),
format!("> {short_id} {agent_type}"),
Style::default().fg(Color::Yellow),
))
} else {
Line::from(text)
Line::from(format!(" {short_id} {agent_type}"))
}
})
.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]);
@ -546,11 +536,6 @@ 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 => {
@ -561,17 +546,9 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
};
true
}
// 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();
}
// Navigate back to the session list.
KeyCode::Esc => {
state.go_back();
true
}
// Show quit dialog (don't exit immediately on transcript screen).
@ -585,7 +562,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
true
}
// Jump directly to the search input.
KeyCode::Char('t') | KeyCode::Char('/') => {
KeyCode::Char('t') => {
state.focus = Focus::SearchInput;
true
}
@ -594,7 +571,8 @@ 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
}
@ -605,7 +583,8 @@ 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
}
@ -624,7 +603,8 @@ 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
}
@ -647,10 +627,7 @@ 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);
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);
state.transcript_scroll = state.transcript_scroll.saturating_add(page);
true
}
// Horizontal scroll — only meaningful in ChatLog.
@ -678,36 +655,6 @@ 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,
}
}
@ -777,14 +724,16 @@ 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;
}
@ -806,16 +755,21 @@ 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 };
@ -951,7 +905,11 @@ 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"));
}
@ -972,7 +930,11 @@ 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"));
}
@ -1253,10 +1215,6 @@ 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);
@ -1265,10 +1223,6 @@ 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);
@ -1456,8 +1410,6 @@ 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,12 +38,6 @@ 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,
}
// ---------------------------------------------------------------------------
@ -163,11 +157,6 @@ 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.
///
@ -185,11 +174,7 @@ 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.
@ -255,7 +240,6 @@ impl AppState {
filter_active: String::new(),
filter_history,
filter_history_pos: None,
pending_g: false,
color_enabled,
should_quit: false,
}
@ -306,27 +290,6 @@ 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
@ -474,8 +437,6 @@ 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