Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions cli/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,30 @@ fn human_header(out: &mut String, common: &Transcript<Common>, span: &Span, colo
&format!("{shown} of {}", common.body.len()),
color,
);
if let Some(usage) = common.total_usage()
&& !usage.is_zero()
{
let mut parts = vec![
format!("{} in", usage.input_tokens),
format!("{} out", usage.output_tokens),
];
if let Some(cached) = usage.cache_read_input_tokens
&& cached > 0
{
parts.push(format!("{cached} cached"));
}
if let Some(created) = usage.cache_creation_input_tokens
&& created > 0
{
parts.push(format!("{created} cache write"));
}
human_field(
out,
"Tokens",
&format!("{} ({})", usage.total_tokens(), parts.join(", ")),
color,
);
}
}

/// Render `messages` under `filters`, returning the line index of each
Expand Down Expand Up @@ -548,15 +572,23 @@ fn human_messages(
continue;
}
let ordinal = start + offset + 1;
let role = match message.role {
Role::User => "User",
Role::Assistant => "Assistant",
let role_label = match (message.role, &message.usage) {
(Role::Assistant, Some(u)) if !u.is_zero() => {
format!("Assistant · {} tokens", u.total_tokens())
}
(Role::Assistant, _) => "Assistant".to_string(),
(Role::User, _) => "User".to_string(),
};
lines += out[counted..].bytes().filter(|byte| *byte == b'\n').count();
counted = out.len();
// The rule follows the blank line `human_rule` opens with.
message_starts.push(lines + 1);
human_rule(out, &format!("Message #{ordinal} · {role}"), width, color);
human_rule(
out,
&format!("Message #{ordinal} · {role_label}"),
width,
color,
);
for (index, block) in message.content.iter().enumerate() {
if filters.shows_block(block) {
blocks.render(out, (offset, index), block);
Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ A session id is any unambiguous prefix of the full id, or the session's exact ti
- A [Simple](formats/simple.md) document instead of an id — `txcript continue ./run.json --with claude_code`, or `my-agent | txcript continue - --with claude_code` — brings any agent's transcript in the same way; `--with` is required since a document has no harness of its own.
- The launch command is per-harness and overridable: set `TRANSCRIPT_<HARNESS>_RESUME_CMD` to a `{id}` template, e.g. `TRANSCRIPT_CODEX_RESUME_CMD="codex resume {id}"`.

`view` in a terminal opens a built-in pager: `u`, `a`, `t`, and `r` hide or show user messages, assistant messages, tool calls, and reasoning; `]` and `[` jump between messages; `/` searches what is shown. Images are drawn inline on terminals that can show them (Ghostty, kitty, WezTerm, Konsole). Set `TXCRIPT_PAGER` to use an external pager instead, or pass `--no-pager` to print the view directly. Piped or redirected, `view` prints the same compact text the MCP server serves. Either way each message is numbered by a `── #N ──` rule, and `#range` selects messages by those printed ordinals, 1-based and inclusive:
`view` in a terminal opens a built-in pager: `u`, `a`, `t`, and `r` hide or show user messages, assistant messages, tool calls, and reasoning; `]` and `[` jump between messages; `/` searches what is shown. Images are drawn inline on terminals that can show them (Ghostty, kitty, WezTerm, Konsole). Session metadata in the header includes total token accounting (`Tokens: <total> (<in> in, <out> out, <cached> cached)`), and assistant turn rules display individual turn token counts (`Message #N · Assistant · <X> tokens`) when reported by the source harness. Set `TXCRIPT_PAGER` to use an external pager instead, or pass `--no-pager` to print the view directly. Piped or redirected, `view` prints the same compact text the MCP server serves. Either way each message is numbered by a `── #N ──` rule, and `#range` selects messages by those printed ordinals, 1-based and inclusive:

- `abc#7`: message 7 only
- `abc#5-12`: messages 5 through 12
Expand Down
95 changes: 94 additions & 1 deletion src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ pub enum StopReason {
}

/// Token accounting for one assistant turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
Expand All @@ -135,6 +135,63 @@ pub struct Usage {
pub cache_creation_input_tokens: Option<u64>,
}

impl Usage {
/// Compute total tokens represented by this usage record, including cached and creation tokens.
#[must_use]
pub fn total_tokens(&self) -> u64 {
self.input_tokens
.saturating_add(self.output_tokens)
.saturating_add(self.cache_read_input_tokens.unwrap_or(0))
.saturating_add(self.cache_creation_input_tokens.unwrap_or(0))
}

/// Whether all token counts in this record are zero or unset.
#[must_use]
pub fn is_zero(&self) -> bool {
self.input_tokens == 0
&& self.output_tokens == 0
&& self.cache_read_input_tokens.unwrap_or(0) == 0
&& self.cache_creation_input_tokens.unwrap_or(0) == 0
}
}

impl std::ops::Add for Usage {
type Output = Self;

fn add(self, rhs: Self) -> Self::Output {
let combine_opt = |a: Option<u64>, b: Option<u64>| match (a, b) {
(Some(x), Some(y)) => Some(x.saturating_add(y)),
(Some(x), None) | (None, Some(x)) => Some(x),
(None, None) => None,
};

Self {
input_tokens: self.input_tokens.saturating_add(rhs.input_tokens),
output_tokens: self.output_tokens.saturating_add(rhs.output_tokens),
cache_read_input_tokens: combine_opt(
self.cache_read_input_tokens,
rhs.cache_read_input_tokens,
),
cache_creation_input_tokens: combine_opt(
self.cache_creation_input_tokens,
rhs.cache_creation_input_tokens,
),
}
}
}

impl std::ops::AddAssign for Usage {
fn add_assign(&mut self, rhs: Self) {
*self = *self + rhs;
}
}

impl std::iter::Sum for Usage {
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
iter.fold(Self::default(), |acc, u| acc + u)
}
}

/// A base64-encoded inline image.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageSource {
Expand Down Expand Up @@ -574,6 +631,42 @@ mod tests {
assert!(matches!(tool, Tool::Bash { .. }));
assert_eq!(tool.to_canonical().1, input);
}

#[test]
fn usage_arithmetic_and_aggregation() {
let u1 = Usage {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: Some(25),
cache_creation_input_tokens: None,
};
let u2 = Usage {
input_tokens: 200,
output_tokens: 150,
cache_read_input_tokens: Some(30),
cache_creation_input_tokens: Some(10),
};

assert_eq!(u1.total_tokens(), 175);
assert_eq!(u2.total_tokens(), 390);
assert!(!u1.is_zero());
assert!(Usage::default().is_zero());

let sum = u1 + u2;
assert_eq!(sum.input_tokens, 300);
assert_eq!(sum.output_tokens, 200);
assert_eq!(sum.cache_read_input_tokens, Some(55));
assert_eq!(sum.cache_creation_input_tokens, Some(10));
assert_eq!(sum.total_tokens(), 565);

let mut acc = u1;
acc += u2;
assert_eq!(acc, sum);

let list = vec![u1, u2, Usage::default()];
let iter_sum: Usage = list.into_iter().sum();
assert_eq!(iter_sum, sum);
}
}

/// One find/replace within a [`Tool::MultiEdit`].
Expand Down
31 changes: 24 additions & 7 deletions src/harness/cursor_desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,19 +332,36 @@ fn flush(messages: &mut Vec<Message>, assistant: &mut Option<Message>) {
fn bubble_usage(bubble: &Value) -> Option<Usage> {
let input = bubble
.pointer("/tokenCount/inputTokens")
.or_else(|| bubble.pointer("/tokenCount/promptTokens"))
.or_else(|| bubble.pointer("/tokenUsage/promptTokens"))
.or_else(|| bubble.pointer("/tokenUsage/inputTokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
let output = bubble
.pointer("/tokenCount/outputTokens")
.or_else(|| bubble.pointer("/tokenCount/completionTokens"))
.or_else(|| bubble.pointer("/tokenUsage/completionTokens"))
.or_else(|| bubble.pointer("/tokenUsage/outputTokens"))
.and_then(Value::as_u64)
.unwrap_or(0);
// All-zero counts are the serializer default, not an observation.
(input > 0 || output > 0).then_some(Usage {
input_tokens: input,
output_tokens: output,
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
})
let cache_read = bubble
.pointer("/tokenCount/cacheReadTokens")
.or_else(|| bubble.pointer("/tokenCount/cachedTokens"))
.or_else(|| bubble.pointer("/tokenUsage/cacheReadTokens"))
.or_else(|| bubble.pointer("/tokenUsage/cachedTokens"))
.and_then(Value::as_u64);
let cache_write = bubble
.pointer("/tokenCount/cacheCreationTokens")
.or_else(|| bubble.pointer("/tokenUsage/cacheCreationTokens"))
.and_then(Value::as_u64);

(input > 0 || output > 0 || cache_read.unwrap_or(0) > 0 || cache_write.unwrap_or(0) > 0)
.then_some(Usage {
input_tokens: input,
output_tokens: output,
cache_read_input_tokens: cache_read,
cache_creation_input_tokens: cache_write,
})
}

/// Emit the `ToolUse` on the assistant message and its paired result on the
Expand Down
Loading
Loading