Compare commits

...

10 Commits

Author SHA1 Message Date
Elijah Voigt c3721bc9ef feat(tui): add app state model and screen enum
Defines Screen, Focus, AppState, SessionListItem with state transition
methods (new, enter_transcript, go_back). Reuses existing RawEntry and
AgentRef types. Includes 11 unit tests.

Closes claudbg-nq36

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 3e9e6f7ced feat(tui): add ratatui + crossterm dependencies
Adds ratatui 0.29 with crossterm backend feature to Cargo.toml.
crossterm 0.28.1 is pulled in transitively via ratatui's crossterm feature.

Closes claudbg-78xt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 0af7b50095 fix(cli): improve --include help text with valid values and example
Explicitly list 'thinking' and 'output' as valid values and show an
example invocation so users can discover options from --help.

Closes claudbg-a532

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 68a795bacd feat(sessions): add Sub-agents count column to sessions list
Call discover_agents_for_session for each session and show the real
count in a new Sub-agents column. JSON and XML output updated to include
the subagents field.

Closes claudbg-xpzp

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 4fd09045d0 fix(transcribe): respect --verbose for tool use input truncation
Replace hardcoded 120-char cap with usize::MAX when opts.verbose is set.
Also add a trailing ellipsis indicator when content is actually truncated.

Closes claudbg-kg0v

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 1455859825 fix(transcribe): show tool results by default with truncation
Remove the opts.include.output gate in render_entry_text so tool results
are always visible. Truncated to 200 chars by default; uncapped with
--verbose.

Closes claudbg-zy1p

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt dbffc151fb fix(transcribe): format tool_calls as readable list in header
Replace {:?} debug format with entries sorted by count descending,
formatted as "Name×N" joined by ", " instead of raw HashMap output.

Closes claudbg-f4ot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 123913aefd fix(dump): collapse consecutive identical rows in sessions dump
Add collapse_dump_rows() post-processing pass that merges runs of rows
with identical type/role/content into a single row showing the seq range
as "N-M". Applies to table, JSON, and XML output uniformly.

Closes claudbg-d9ev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt d9ceb916ba fix(output): respect terminal width and truncate long project paths
Replace hardcoded FALLBACK_WIDTH=200 with ContentArrangement::Dynamic
so comfy-table auto-fits to the actual terminal. Truncate project paths
to the last ~40 chars with an ellipsis prefix in sessions list.

Closes claudbg-6dgc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt f3258ee2b5 fix(output): emit array-of-objects for JSON output
sessions list/dump and agents list/dump were passing Vec<Vec<String>>
directly to render_json, producing unlabeled arrays. Now each row is
mapped to a serde_json object with named keys before serializing.

Closes claudbg-pfa5

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

@ -1,11 +1,11 @@
---
# claudbg-6dgc
title: Table overflows terminal — truncate long columns and respect terminal width
status: todo
status: completed
type: bug
priority: normal
created_at: 2026-03-30T04:36:31Z
updated_at: 2026-03-30T04:41:14Z
updated_at: 2026-03-30T05:06:58Z
parent: claudbg-tci9
---
@ -28,3 +28,8 @@ parent: claudbg-tci9
- `src/output/table.rs``render_table`, `FALLBACK_WIDTH` constant
- `src/commands/sessions.rs``list()` function that calls `render_table`
## Summary of Changes
- Removed hardcoded FALLBACK_WIDTH=200 from table.rs; table now uses ContentArrangement::Dynamic for automatic terminal width fitting
- In sessions list(), truncate project path to last ~40 chars with ellipsis prefix when longer

@ -1,10 +1,11 @@
---
# claudbg-78xt
title: 'TUI: add ratatui + crossterm dependencies'
status: todo
status: completed
type: task
priority: normal
created_at: 2026-03-30T04:45:06Z
updated_at: 2026-03-30T04:45:06Z
updated_at: 2026-03-30T16:37:06Z
parent: claudbg-i6l2
---
@ -32,3 +33,5 @@ Run `cargo check` to confirm the dependency resolves and there are no conflicts
## Relevant files
- `Cargo.toml`
## Summary of Changes\n\nAdded to Cargo.toml. Ratatui 0.29.0 resolved with crossterm 0.28.1 pulled in transitively. passes with no conflicts.

@ -1,11 +1,11 @@
---
# claudbg-a532
title: '`--include` help text should enumerate valid values'
status: todo
status: completed
type: bug
priority: low
created_at: 2026-03-30T04:38:03Z
updated_at: 2026-03-30T04:41:14Z
updated_at: 2026-03-30T05:16:36Z
parent: claudbg-tci9
---
@ -35,3 +35,7 @@ Since `IncludeList` is a comma-separated composite (not a single enum value), op
## Relevant files
- `src/cli.rs``IncludeList`, `GlobalOpts`, the `include` field annotation
## Summary of Changes
Updated the doc comment on the --include field in src/cli.rs to explicitly enumerate valid values (thinking, output) and provide an example.

@ -1,11 +1,11 @@
---
# claudbg-d9ev
title: '`sessions dump` should collapse consecutive identical rows'
status: todo
status: completed
type: bug
priority: normal
created_at: 2026-03-30T04:38:42Z
updated_at: 2026-03-30T04:41:14Z
updated_at: 2026-03-30T05:08:51Z
parent: claudbg-tci9
---
@ -41,3 +41,7 @@ After building the `rows` vector in `dump()`, run a post-processing pass: iterat
## Relevant files
- `src/commands/sessions.rs``dump()` function, row-building loop
## Summary of Changes
Added collapse_dump_rows() post-processing pass in sessions.rs that merges consecutive rows with identical type/role/content into a single row with seq shown as 'N-M' range. Applied before output format branching so it affects table, JSON, and XML output uniformly.

@ -1,10 +1,11 @@
---
# claudbg-f4ot
title: Transcription header shows tools as debug HashMap, not readable list
status: todo
status: completed
type: bug
priority: normal
created_at: 2026-03-30T04:43:50Z
updated_at: 2026-03-30T04:43:50Z
updated_at: 2026-03-30T05:10:08Z
parent: claudbg-8vpb
---
@ -46,3 +47,7 @@ Format `stats.tool_calls` by iterating entries, sorting by count descending, and
- `src/commands/sessions.rs` — transcribe function, `Tools:` println
- `src/commands/agents.rs` — transcribe function (same issue)
- `src/models/stats.rs``tool_calls: HashMap<String, u64>`
## Summary of Changes
In sessions.rs transcribe function, replaced {:?} debug format with a sorted, human-readable format: entries sorted by count descending, formatted as 'Name×N', joined by ', '.

@ -1,10 +1,11 @@
---
# claudbg-kg0v
title: Tool use input truncated at 120 chars regardless of --verbose
status: todo
status: completed
type: bug
priority: normal
created_at: 2026-03-30T04:44:31Z
updated_at: 2026-03-30T04:44:31Z
updated_at: 2026-03-30T05:13:37Z
parent: claudbg-8vpb
---
@ -47,3 +48,7 @@ Same change needed in `agents.rs`.
- `src/commands/sessions.rs``render_entry_text`, `ToolUse` branch (~lines 107111)
- `src/commands/agents.rs``render_entry_text`, `ToolUse` branch (~lines 6872)
## Summary of Changes
In sessions.rs and agents.rs render_entry_text ToolUse branch: replaced hardcoded 120-char cap with cap=usize::MAX when opts.verbose, 120 otherwise. Added ellipsis indicator when content is actually truncated.

@ -1,11 +1,11 @@
---
# claudbg-nq36
title: 'TUI: app state model and screen enum'
status: todo
status: in-progress
type: task
priority: normal
created_at: 2026-03-30T04:45:19Z
updated_at: 2026-03-30T04:49:03Z
updated_at: 2026-03-30T16:37:25Z
parent: claudbg-i6l2
blocked_by:
- claudbg-78xt

@ -1,11 +1,11 @@
---
# claudbg-pfa5
title: JSON output is array-of-arrays instead of array-of-objects
status: todo
status: completed
type: bug
priority: normal
created_at: 2026-03-30T04:37:05Z
updated_at: 2026-03-30T04:41:14Z
updated_at: 2026-03-30T05:05:12Z
parent: claudbg-tci9
---
@ -38,3 +38,7 @@ Output should be an array of objects with named keys:
- `src/commands/sessions.rs``list()` and `dump()` JSON output branches
- `src/output/json.rs` — generic `render_json`
## Summary of Changes
Fixed JSON output in both sessions.rs and agents.rs to produce array-of-objects instead of array-of-arrays. Each row is now mapped to a serde_json object with named keys before serializing. Keys: sessions list (id, date, project, model, messages, subagents), sessions dump (seq, timestamp, type, role, content), agents list (id, type, file, modified), agents dump (seq, timestamp, type, role, content).

@ -1,11 +1,11 @@
---
# claudbg-xpzp
title: '`sessions list` should show sub-agent run count per session'
status: todo
status: completed
type: bug
priority: normal
created_at: 2026-03-30T04:40:53Z
updated_at: 2026-03-30T04:43:26Z
updated_at: 2026-03-30T05:15:27Z
parent: claudbg-tci9
blocked_by:
- claudbg-33n0
@ -40,3 +40,7 @@ The existing `discover_agents_for_session(session_file)` function currently look
- `src/commands/sessions.rs``list()` function
- `src/parser/discovery.rs``discover_agents_for_session`
## Summary of Changes
In sessions.rs list(): built a session_id->file_path map from discovered sessions, then for each DB row called discover_agents_for_session to get the real count. Added 'Sub-agents' column to table headers and row data. Updated JSON and XML output to include the subagents field.

@ -1,10 +1,11 @@
---
# claudbg-zy1p
title: Tool results hidden by default in transcriptions — should show with truncation
status: todo
status: completed
type: bug
priority: normal
created_at: 2026-03-30T04:44:18Z
updated_at: 2026-03-30T04:44:18Z
updated_at: 2026-03-30T05:12:16Z
parent: claudbg-8vpb
---
@ -41,3 +42,7 @@ Also apply the same change to the `--verbose` path: when `opts.verbose` is true,
- `src/commands/sessions.rs``render_entry_text`, `ToolResult` branch (~line 113131)
- `src/commands/agents.rs``render_entry_text`, same branch (~line 7492)
## Summary of Changes
Removed opts.include.output gate in render_entry_text in both sessions.rs and agents.rs. Tool results now always shown, truncated at 200 chars by default, uncapped with --verbose.

242
Cargo.lock generated

@ -11,6 +11,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
@ -143,6 +149,21 @@ dependencies = [
"serde",
]
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.58"
@ -241,6 +262,7 @@ dependencies = [
"clap",
"comfy-table",
"libsql",
"ratatui",
"serde",
"serde_json",
"tempfile",
@ -270,9 +292,23 @@ version = "7.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
dependencies = [
"crossterm",
"crossterm 0.29.0",
"unicode-segmentation",
"unicode-width",
"unicode-width 0.2.0",
]
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
@ -281,6 +317,22 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm"
version = "0.29.0"
@ -304,6 +356,40 @@ dependencies = [
"winapi",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "document-features"
version = "0.2.12"
@ -466,6 +552,8 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
@ -520,6 +608,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "2.13.0"
@ -532,12 +626,43 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@ -662,6 +787,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "memchr"
version = "2.8.0"
@ -681,6 +815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@ -739,6 +874,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "peeking_take_while"
version = "0.1.2"
@ -785,6 +926,27 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm 0.28.1",
"indoc",
"instability",
"itertools",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -861,6 +1023,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -922,6 +1090,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@ -954,12 +1143,40 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "syn"
version = "2.0.117"
@ -1095,11 +1312,28 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.2.2"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unicode-xid"

@ -21,6 +21,7 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.23", features = ["v4", "serde"] }
thiserror = "2.0"
comfy-table = "7.2"
ratatui = { version = "0.29", features = ["crossterm"] }
[dev-dependencies]
tempfile = "3"

@ -72,7 +72,8 @@ pub struct GlobalOpts {
/// Show full UUIDs and extra detail.
#[arg(long, global = true)]
pub verbose: bool,
/// Comma-separated content to include: thinking, output.
/// Comma-separated list of extra content to include in transcriptions.
/// Valid values: thinking, output. Example: --include thinking,output
#[arg(long, global = true, default_value = "")]
pub include: IncludeList,
}

@ -44,7 +44,7 @@ fn content_preview(entry: &crate::models::session::RawEntry, max_len: usize) ->
/// Render a single entry to stdout in human-readable text format.
///
/// Thinking blocks are shown only when `opts.include.thinking` is set.
/// Tool result blocks are shown only when `opts.include.output` is set.
/// Tool result blocks are always shown, truncated to 200 chars unless `opts.verbose`.
fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) {
let Some(msg) = &entry.message else { return };
let role = msg.role.as_deref().unwrap_or("?");
@ -67,14 +67,15 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
}
crate::models::session::ContentBlock::ToolUse { name, input, .. } => {
let input_preview = serde_json::to_string(input).unwrap_or_default();
let boundary = input_preview.floor_char_boundary(120);
let cap = if opts.verbose { usize::MAX } else { 120 };
let boundary = input_preview.floor_char_boundary(cap);
let input_short = &input_preview[..boundary];
println!("[tool: {name}] {input_short}");
let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" };
println!("[tool: {name}] {input_short}{ellipsis}");
}
crate::models::session::ContentBlock::ToolResult {
content, is_error, ..
} => {
if opts.include.output {
let err_flag = if is_error.unwrap_or(false) {
" (error)"
} else {
@ -86,6 +87,9 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
.unwrap_or_else(|| {
content.as_ref().map(|c| c.to_string()).unwrap_or_default()
});
if opts.verbose {
println!("[tool_result{err_flag}]: {preview}");
} else {
let boundary = preview.floor_char_boundary(200);
let short = &preview[..boundary];
println!("[tool_result{err_flag}]: {short}");
@ -185,7 +189,20 @@ pub async fn list(session_id: &str, opts: &crate::cli::GlobalOpts) -> Result<()>
crate::cli::OutputFormat::Table => {
crate::output::render_table(&["Agent ID", "Type", "File", "Modified"], &rows)?
}
crate::cli::OutputFormat::Json => crate::output::render_json(&rows)?,
crate::cli::OutputFormat::Json => {
let objects: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"id": r[0],
"type": r[1],
"file": r[2],
"modified": r[3],
})
})
.collect();
crate::output::render_json(&objects)?
}
crate::cli::OutputFormat::Xml => {
crate::output::render_xml_rows(&["agent_id", "type", "file", "modified"], &rows)?
}
@ -239,7 +256,21 @@ pub async fn dump(
crate::cli::OutputFormat::Table => {
crate::output::render_table(&["#", "Timestamp", "Type", "Role", "Content"], &rows)?
}
crate::cli::OutputFormat::Json => crate::output::render_json(&rows)?,
crate::cli::OutputFormat::Json => {
let objects: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"seq": r[0],
"timestamp": r[1],
"type": r[2],
"role": r[3],
"content": r[4],
})
})
.collect();
crate::output::render_json(&objects)?
}
crate::cli::OutputFormat::Xml => {
crate::output::render_xml_rows(&["seq", "timestamp", "type", "role", "content"], &rows)?
}
@ -669,9 +700,9 @@ mod tests {
render_entry_text(&entry, &opts); // must not panic
}
/// `render_entry_text` does not panic when tool results are present but gated off.
/// `render_entry_text` always shows tool results (truncated by default) without panicking.
#[test]
fn render_entry_text_tool_result_gated() {
fn render_entry_text_tool_result_always_shown() {
let entry = make_raw_entry(
"user",
"user",
@ -681,7 +712,7 @@ mod tests {
is_error: Some(false),
}]),
);
let opts = default_opts();
let opts = default_opts(); // verbose = false → truncated at 200 chars
render_entry_text(&entry, &opts); // must not panic
}
}

@ -83,7 +83,7 @@ fn content_preview(entry: &crate::models::session::RawEntry, max_len: usize) ->
/// Render a single entry to stdout in human-readable text format.
///
/// Thinking blocks are shown only when `opts.include.thinking` is set.
/// Tool result blocks are shown only when `opts.include.output` is set.
/// Tool result blocks are always shown, truncated to 200 chars unless `opts.verbose`.
fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) {
let Some(msg) = &entry.message else { return };
let role = msg.role.as_deref().unwrap_or("?");
@ -106,14 +106,15 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
}
crate::models::session::ContentBlock::ToolUse { name, input, .. } => {
let input_preview = serde_json::to_string(input).unwrap_or_default();
let boundary = input_preview.floor_char_boundary(120);
let cap = if opts.verbose { usize::MAX } else { 120 };
let boundary = input_preview.floor_char_boundary(cap);
let input_short = &input_preview[..boundary];
println!("[tool: {name}] {input_short}");
let ellipsis = if !opts.verbose && input_preview.len() > 120 { "…" } else { "" };
println!("[tool: {name}] {input_short}{ellipsis}");
}
crate::models::session::ContentBlock::ToolResult {
content, is_error, ..
} => {
if opts.include.output {
let err_flag = if is_error.unwrap_or(false) {
" (error)"
} else {
@ -125,6 +126,9 @@ fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli
.unwrap_or_else(|| {
content.as_ref().map(|c| c.to_string()).unwrap_or_default()
});
if opts.verbose {
println!("[tool_result{err_flag}]: {preview}");
} else {
let boundary = preview.floor_char_boundary(200);
let short = &preview[..boundary];
println!("[tool_result{err_flag}]: {short}");
@ -157,6 +161,12 @@ pub async fn list(opts: &crate::cli::GlobalOpts) -> Result<()> {
crate::db::sync::ensure_synced(&db, session_ref).await?;
}
// Build a map from session_id -> file_path for agent discovery.
let session_file_map: std::collections::HashMap<String, std::path::PathBuf> = sessions
.into_iter()
.map(|sr| (sr.session_id, sr.file_path))
.collect();
// Query DB for display.
let conn = db
.connect()
@ -189,22 +199,63 @@ pub async fn list(opts: &crate::cli::GlobalOpts) -> Result<()> {
crate::util::short_id(&session_id).to_string()
};
const MAX_PATH_LEN: usize = 40;
let display_path = if project_path.len() > MAX_PATH_LEN {
let boundary = project_path
.char_indices()
.rev()
.map(|(i, _)| i)
.nth(MAX_PATH_LEN - 1)
.unwrap_or(0);
format!("…{}", &project_path[boundary..])
} else {
project_path
};
// Count sub-agents for this session.
let agent_count = if let Some(file_path) = session_file_map.get(&session_id) {
crate::parser::discovery::discover_agents_for_session(file_path)
.unwrap_or_default()
.len()
} else {
0
};
rows.push(vec![
display_id,
last_msg_at,
project_path,
display_path,
model,
message_count.to_string(),
agent_count.to_string(),
]);
}
let output = match opts.output {
crate::cli::OutputFormat::Table => {
crate::output::render_table(&["ID", "Date", "Project", "Model", "Messages"], &rows)?
crate::output::render_table(
&["ID", "Date", "Project", "Model", "Messages", "Sub-agents"],
&rows,
)?
}
crate::cli::OutputFormat::Json => {
let objects: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"id": r[0],
"date": r[1],
"project": r[2],
"model": r[3],
"messages": r[4],
"subagents": r[5],
})
})
.collect();
crate::output::render_json(&objects)?
}
crate::cli::OutputFormat::Json => crate::output::render_json(&rows)?,
crate::cli::OutputFormat::Xml => crate::output::render_xml_rows(
&["session_id", "date", "project", "model", "messages"],
&["session_id", "date", "project", "model", "messages", "subagents"],
&rows,
)?,
};
@ -289,11 +340,30 @@ pub async fn dump(id: &str, follow: bool, opts: &crate::cli::GlobalOpts) -> Resu
]);
}
// Collapse consecutive rows that are identical in type (col 2), role (col 3),
// and content preview (col 4). Collapsed rows show a "N-M" range in the seq
// column and retain the first row's timestamp.
let rows = collapse_dump_rows(rows);
let output = match opts.output {
crate::cli::OutputFormat::Table => {
crate::output::render_table(&["#", "Timestamp", "Type", "Role", "Content"], &rows)?
}
crate::cli::OutputFormat::Json => crate::output::render_json(&rows)?,
crate::cli::OutputFormat::Json => {
let objects: Vec<serde_json::Value> = rows
.iter()
.map(|r| {
serde_json::json!({
"seq": r[0],
"timestamp": r[1],
"type": r[2],
"role": r[3],
"content": r[4],
})
})
.collect();
crate::output::render_json(&objects)?
}
crate::cli::OutputFormat::Xml => {
crate::output::render_xml_rows(&["seq", "timestamp", "type", "role", "content"], &rows)?
}
@ -302,6 +372,48 @@ pub async fn dump(id: &str, follow: bool, opts: &crate::cli::GlobalOpts) -> Resu
Ok(())
}
/// Collapse consecutive duplicate rows in a dump row set.
///
/// Rows are `[seq, timestamp, type, role, content]`. Two consecutive rows are
/// considered duplicates when their `type` (index 2), `role` (index 3), and
/// `content` (index 4) are all equal. A run of N identical rows is collapsed
/// into a single row whose `seq` field shows the range `"first-last"` (e.g.
/// `"555-661"`). Single (non-repeated) rows are left unchanged.
fn collapse_dump_rows(rows: Vec<Vec<String>>) -> Vec<Vec<String>> {
if rows.is_empty() {
return rows;
}
let mut collapsed: Vec<Vec<String>> = Vec::new();
let mut run_start = rows[0].clone();
let mut run_end_seq = rows[0][0].clone();
for row in rows.into_iter().skip(1) {
if row[2] == run_start[2] && row[3] == run_start[3] && row[4] == run_start[4] {
// Extend the current run.
run_end_seq = row[0].clone();
} else {
// Flush the current run.
let mut flushed = run_start.clone();
if flushed[0] != run_end_seq {
flushed[0] = format!("{}-{}", flushed[0], run_end_seq);
}
collapsed.push(flushed);
run_start = row;
run_end_seq = run_start[0].clone();
}
}
// Flush the final run.
let mut flushed = run_start;
if flushed[0] != run_end_seq {
flushed[0] = format!("{}-{}", flushed[0], run_end_seq);
}
collapsed.push(flushed);
collapsed
}
/// Follow mode for `sessions dump`: polls the session file and prints new entries
/// as they arrive, bypassing the DB cache entirely.
async fn dump_follow(id: &str, opts: &crate::cli::GlobalOpts) -> Result<()> {
@ -424,7 +536,13 @@ pub async fn transcribe(id: &str, follow: bool, opts: &crate::cli::GlobalOpts) -
stats.cache_read_tokens,
stats.cache_creation_tokens
);
println!("Tools: {:?}", stats.tool_calls);
let mut tool_entries: Vec<(&String, &u64)> = stats.tool_calls.iter().collect();
tool_entries.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
let tools_str: Vec<String> = tool_entries
.iter()
.map(|(name, count)| format!("{}×{}", name, count))
.collect();
println!("Tools: {}", tools_str.join(", "));
println!("Duration: {}ms", stats.duration_ms);
println!("{}", "─".repeat(80));
@ -714,9 +832,117 @@ mod tests {
render_entry_text(&entry, &opts); // must not panic
}
/// `render_entry_text` does not print tool results when `include.output` is false.
/// `collapse_dump_rows` leaves an empty input unchanged.
#[test]
fn collapse_dump_rows_empty() {
assert_eq!(collapse_dump_rows(vec![]), Vec::<Vec<String>>::new());
}
/// `collapse_dump_rows` leaves a single row unchanged (no range suffix).
#[test]
fn collapse_dump_rows_single_row() {
let rows = vec![vec![
"1".to_string(),
"ts".to_string(),
"progress".to_string(),
"".to_string(),
"msg".to_string(),
]];
let result = collapse_dump_rows(rows);
assert_eq!(result.len(), 1);
assert_eq!(result[0][0], "1");
}
/// `collapse_dump_rows` does not add a range to two different rows.
#[test]
fn collapse_dump_rows_no_duplicates() {
let rows = vec![
vec![
"1".to_string(),
"ts1".to_string(),
"user".to_string(),
"user".to_string(),
"hello".to_string(),
],
vec![
"2".to_string(),
"ts2".to_string(),
"assistant".to_string(),
"assistant".to_string(),
"world".to_string(),
],
];
let result = collapse_dump_rows(rows);
assert_eq!(result.len(), 2);
assert_eq!(result[0][0], "1");
assert_eq!(result[1][0], "2");
}
/// `collapse_dump_rows` collapses a run of identical type/role/content rows.
#[test]
fn collapse_dump_rows_collapses_run() {
let make = |seq: &str, ts: &str| {
vec![
seq.to_string(),
ts.to_string(),
"progress".to_string(),
"".to_string(),
"busy".to_string(),
]
};
let rows = vec![
make("1", "t1"),
make("2", "t2"),
make("3", "t3"),
make("4", "t4"),
];
let result = collapse_dump_rows(rows);
assert_eq!(result.len(), 1);
assert_eq!(result[0][0], "1-4", "seq should show range");
assert_eq!(result[0][1], "t1", "should keep first timestamp");
assert_eq!(result[0][2], "progress");
}
/// `collapse_dump_rows` handles mixed runs correctly.
#[test]
fn collapse_dump_rows_mixed_runs() {
let progress = |seq: &str| {
vec![
seq.to_string(),
"ts".to_string(),
"progress".to_string(),
"".to_string(),
"busy".to_string(),
]
};
let user = |seq: &str| {
vec![
seq.to_string(),
"ts".to_string(),
"user".to_string(),
"user".to_string(),
"hello".to_string(),
]
};
// progress x3, user x1, progress x2
let rows = vec![
progress("1"),
progress("2"),
progress("3"),
user("4"),
progress("5"),
progress("6"),
];
let result = collapse_dump_rows(rows);
assert_eq!(result.len(), 3);
assert_eq!(result[0][0], "1-3");
assert_eq!(result[1][0], "4");
assert_eq!(result[2][0], "5-6");
}
/// `render_entry_text` always shows tool results (truncated by default) without panicking.
#[test]
fn render_entry_text_tool_result_gated() {
fn render_entry_text_tool_result_always_shown() {
let entry = make_raw_entry(
"user",
"user",
@ -726,7 +952,7 @@ mod tests {
is_error: Some(false),
}]),
);
let opts = default_opts(); // include.output = false
let opts = default_opts(); // verbose = false → truncated at 200 chars
render_entry_text(&entry, &opts); // must not panic
}
}

@ -7,6 +7,7 @@ pub mod error;
pub mod models;
pub mod output;
pub mod parser;
pub mod tui;
pub mod util;
#[cfg(test)]

@ -12,12 +12,6 @@ use crate::error::Result;
pub fn render_table(headers: &[&str], rows: &[Vec<String>]) -> Result<String> {
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::Dynamic);
// Ensure a sensible minimum width so content is never truncated in non-interactive
// environments (CI, Nix sandbox, pipes) where the terminal may report a very narrow
// width or no width at all.
const FALLBACK_WIDTH: u16 = 200;
let width = table.width().unwrap_or(FALLBACK_WIDTH).max(FALLBACK_WIDTH);
table.set_width(width);
table.set_header(headers);
for row in rows {
table.add_row(row);

@ -0,0 +1,6 @@
//! TUI module — terminal user interface for claudbg.
//!
//! This module will grow to include rendering and event-handling logic.
//! For now it exposes the application state model used by all TUI screens.
pub mod state;

@ -0,0 +1,336 @@
//! TUI application state model.
//!
//! Defines the central [`AppState`] struct and the [`Screen`] / [`Focus`] enums
//! that drive the TUI navigation model. This module holds pure data — no
//! terminal I/O or rendering logic lives here.
use crate::models::session::RawEntry;
use crate::parser::discovery::AgentRef;
// ---------------------------------------------------------------------------
// Lightweight display types
// ---------------------------------------------------------------------------
/// Lightweight summary of a session, used in the session-list screen.
///
/// Carries only the fields needed to render the list rows; heavier data
/// (transcript entries, sub-agent details) is loaded on demand when the user
/// navigates into a session.
#[derive(Debug, Clone)]
pub struct SessionListItem {
/// 8-character prefix of the session UUID (display form).
pub short_id: String,
/// Full session UUID (used when resolving to load transcript entries).
pub full_id: String,
/// Human-readable date string (e.g. `"2025-03-30 14:22:01"`).
pub date: String,
/// Project path recovered from the `cwd` field of the first JSONL line.
/// Falls back to an empty string when unavailable.
pub project: String,
/// Model identifier from the first assistant message (e.g. `"claude-sonnet-4-6"`).
/// Empty string when no assistant message is present.
pub model: String,
/// Total number of user + assistant messages in the session.
pub msg_count: usize,
/// Number of sub-agent runs attached to this session.
pub agent_count: usize,
}
// ---------------------------------------------------------------------------
// Screen enum
// ---------------------------------------------------------------------------
/// The active screen (navigation state) of the TUI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Screen {
/// The session list overview — the TUI's home screen.
SessionList,
/// A session transcript view for the given session UUID.
Transcript {
/// Full UUID of the session being viewed.
session_id: String,
},
/// A sub-agent transcript view nested under a parent session.
SubagentTranscript {
/// Full UUID of the parent session.
parent_session_id: String,
/// The agent's UUID (as returned by [`AgentRef::agent_id`]).
agent_id: String,
},
}
// ---------------------------------------------------------------------------
// Focus enum
// ---------------------------------------------------------------------------
/// Which panel currently holds keyboard focus on the transcript screen.
///
/// On the session-list screen `Focus` is not meaningful; it is tracked here
/// so the value is preserved when the user navigates back and forth.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Focus {
/// The main chat-log pane (default).
ChatLog,
/// The sub-agents side panel (shown when sub-agents are present).
SubagentsPanel,
}
impl Default for Focus {
fn default() -> Self {
Self::ChatLog
}
}
// ---------------------------------------------------------------------------
// AppState
// ---------------------------------------------------------------------------
/// Central application state for the TUI.
///
/// All mutable state that drives rendering and event handling lives here.
/// Rendering code reads this struct; event handlers mutate it.
#[derive(Debug)]
pub struct AppState {
// ── Navigation ──────────────────────────────────────────────────────────
/// Currently displayed screen.
pub screen: Screen,
// ── Session list ────────────────────────────────────────────────────────
/// All sessions, sorted most-recent-first. Loaded once at startup.
pub sessions: Vec<SessionListItem>,
/// Index of the highlighted row in the session list.
pub list_selected: usize,
// ── Transcript ──────────────────────────────────────────────────────────
/// Parsed JSONL entries for the currently viewed session or sub-agent.
pub transcript_entries: Vec<RawEntry>,
/// Vertical scroll offset (lines from the top of the chat log).
pub transcript_scroll: usize,
/// Horizontal scroll offset (columns from the left edge of the chat log).
pub transcript_h_scroll: usize,
// ── Sub-agents panel ────────────────────────────────────────────────────
/// Sub-agent references for the currently viewed session.
pub subagents: Vec<AgentRef>,
/// Index of the highlighted row in the sub-agents panel.
pub subagent_selected: usize,
// ── Focus ───────────────────────────────────────────────────────────────
/// Which panel currently holds keyboard focus.
pub focus: Focus,
// ── Modals ──────────────────────────────────────────────────────────────
/// Whether the "quit?" confirmation dialog is visible.
pub show_quit_dialog: bool,
/// Whether the keyboard-shortcut help overlay is visible.
pub show_help: bool,
}
impl AppState {
/// Create a fresh [`AppState`] ready for the session-list screen.
///
/// All data fields are initialised to empty / zero so the TUI can render
/// immediately; the caller is responsible for populating [`AppState::sessions`]
/// before the first frame is drawn.
pub fn new() -> Self {
Self {
screen: Screen::SessionList,
sessions: Vec::new(),
list_selected: 0,
transcript_entries: Vec::new(),
transcript_scroll: 0,
transcript_h_scroll: 0,
subagents: Vec::new(),
subagent_selected: 0,
focus: Focus::default(),
show_quit_dialog: false,
show_help: false,
}
}
/// Transition to the transcript screen for a session.
///
/// Resets all transcript / sub-agent scroll and selection state so that
/// the new view starts from the top. The caller is responsible for
/// loading [`AppState::transcript_entries`] and [`AppState::subagents`]
/// after calling this method.
pub fn enter_transcript(&mut self, session_id: impl Into<String>) {
self.screen = Screen::Transcript {
session_id: session_id.into(),
};
self.transcript_entries.clear();
self.transcript_scroll = 0;
self.transcript_h_scroll = 0;
self.subagents.clear();
self.subagent_selected = 0;
self.focus = Focus::ChatLog;
}
/// Transition to the sub-agent transcript screen.
///
/// Resets transcript scroll state. The caller is responsible for loading
/// [`AppState::transcript_entries`] for the agent after calling this method.
pub fn enter_subagent_transcript(
&mut self,
parent_session_id: impl Into<String>,
agent_id: impl Into<String>,
) {
self.screen = Screen::SubagentTranscript {
parent_session_id: parent_session_id.into(),
agent_id: agent_id.into(),
};
self.transcript_entries.clear();
self.transcript_scroll = 0;
self.transcript_h_scroll = 0;
self.focus = Focus::ChatLog;
}
/// Return to the session-list screen.
///
/// Clears transcript and sub-agent data. The session list and the
/// previously selected row are preserved so the cursor position is
/// restored after navigating back.
pub fn go_back(&mut self) {
self.screen = Screen::SessionList;
self.transcript_entries.clear();
self.transcript_scroll = 0;
self.transcript_h_scroll = 0;
self.subagents.clear();
self.subagent_selected = 0;
self.focus = Focus::ChatLog;
}
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// `AppState::new()` starts on `Screen::SessionList`.
#[test]
fn new_starts_on_session_list() {
let state = AppState::new();
assert_eq!(state.screen, Screen::SessionList);
}
/// `AppState::new()` starts with empty sessions and zero selection.
#[test]
fn new_starts_with_empty_sessions() {
let state = AppState::new();
assert!(state.sessions.is_empty());
assert_eq!(state.list_selected, 0);
}
/// `AppState::new()` starts with modals closed.
#[test]
fn new_starts_with_modals_closed() {
let state = AppState::new();
assert!(!state.show_quit_dialog);
assert!(!state.show_help);
}
/// `enter_transcript` switches to `Screen::Transcript` and resets scroll.
#[test]
fn enter_transcript_switches_screen() {
let mut state = AppState::new();
state.transcript_scroll = 42;
state.transcript_h_scroll = 7;
state.enter_transcript("abc12345-0000-0000-0000-000000000000");
assert_eq!(
state.screen,
Screen::Transcript {
session_id: "abc12345-0000-0000-0000-000000000000".to_string(),
}
);
assert_eq!(state.transcript_scroll, 0);
assert_eq!(state.transcript_h_scroll, 0);
}
/// `enter_transcript` clears previous transcript entries.
#[test]
fn enter_transcript_clears_entries() {
let mut state = AppState::new();
// Insert a dummy entry via direct field access (simplest approach).
state.transcript_entries.push(RawEntry {
entry_type: Some("user".to_string()),
session_id: None,
parent_session_id: None,
message: None,
system_message: None,
cwd: None,
timestamp: None,
duration_ms: None,
extra: Default::default(),
});
state.enter_transcript("new-session");
assert!(state.transcript_entries.is_empty());
}
/// `enter_subagent_transcript` sets `Screen::SubagentTranscript`.
#[test]
fn enter_subagent_transcript_sets_screen() {
let mut state = AppState::new();
state.enter_subagent_transcript("parent-session", "agent-001");
assert_eq!(
state.screen,
Screen::SubagentTranscript {
parent_session_id: "parent-session".to_string(),
agent_id: "agent-001".to_string(),
}
);
}
/// `go_back` returns to `Screen::SessionList` and clears transcript state.
#[test]
fn go_back_returns_to_session_list() {
let mut state = AppState::new();
state.enter_transcript("some-session");
state.transcript_scroll = 10;
state.go_back();
assert_eq!(state.screen, Screen::SessionList);
assert_eq!(state.transcript_scroll, 0);
assert!(state.transcript_entries.is_empty());
}
/// `go_back` preserves `list_selected`.
#[test]
fn go_back_preserves_list_selected() {
let mut state = AppState::new();
state.list_selected = 5;
state.enter_transcript("some-session");
state.go_back();
assert_eq!(state.list_selected, 5);
}
/// `Focus::default()` is `ChatLog`.
#[test]
fn focus_default_is_chat_log() {
assert_eq!(Focus::default(), Focus::ChatLog);
}
/// `SessionListItem` implements `Debug` and `Clone`.
#[test]
fn session_list_item_debug_clone() {
let item = SessionListItem {
short_id: "abc12345".to_string(),
full_id: "abc12345-0000-0000-0000-000000000000".to_string(),
date: "2025-03-30 14:00:00".to_string(),
project: "/home/user/project".to_string(),
model: "claude-sonnet-4-6".to_string(),
msg_count: 10,
agent_count: 2,
};
let cloned = item.clone();
assert_eq!(cloned.short_id, item.short_id);
let _ = format!("{item:?}");
}
}
Loading…
Cancel
Save