Say what continuing a session will cost, not just how big it is
The import list reported a file size, which predicts the wrong thing. Most
of a large transcript is history from before a compaction, and the model is
not given it again: of the 133 MB session behind the 2026-08-29 incident,
99% of the bytes sat before its last compaction summary.
So each row now carries the tokens the model was actually holding at the
last turn -- the input side of the most recent assistant message's usage,
prompt plus both cache figures, which the CLI records itself rather than
anything inferred from the file. The two disagree in exactly the way that
makes the size misleading. Measured on this machine: `ai-app` is an 80 MB
file with 150k of context, while `ai-app-backup` is 3 MB with 481k. The
smaller file is the more expensive one to continue.
Absent rather than zero when no turn has recorded usage, since a session
with no turns has no figure rather than a figure of none.
The row is three lines instead of one run of separators:
path cut at the head, keeping the tail, and the only thing here that
is cut -- one long value with no natural break, where the lines
below it are short enough to wrap
stats named, context, lines, size
warning only when there is one, in the warning colour
The warning gets its own line and its own colour because it differs in kind
from the stats rather than in degree: those describe the session, it says
whether taking it is safe at all. Colour makes it findable, the words make
it actionable -- "open somewhere else" and "we could not check" are not
distinguishable by shade.
Titles no longer ellipse either; they wrap.
Looked at on the emulator rather than reasoned about, including the states
that are not the default: a long path truncating, a row with no warning,
and a row with no context figure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
This commit is contained in:
1 parent
c1a468432d
commit
19e3525131
3 files changed
+157
-31
No files matched your search
@@ -204,6 +204,14 @@ data class Importable(
|
||||
* a single line can be a megabyte.
|
||||
*/
|
||||
val bytes: Long,
|
||||
/**
|
||||
* Tokens the model was holding at the last turn, or null if no turn has recorded any.
|
||||
*
|
||||
* The number that predicts what continuing this session costs. It disagrees with [bytes] in the
|
||||
* direction that matters: most of a large transcript is usually history from before a
|
||||
* compaction, which the model is no longer given.
|
||||
*/
|
||||
val contextTokens: Long?,
|
||||
/** Whether [title] is a name somebody chose rather than the last thing said in the session. */
|
||||
val named: Boolean,
|
||||
/**
|
||||
@@ -226,6 +234,11 @@ fun fetchImportable(settings: ServerSettings, setup: String): List<Importable> =
|
||||
modified = session.optDouble("modified", 0.0),
|
||||
lines = session.optInt("lines", 0),
|
||||
bytes = session.optLong("bytes", 0L),
|
||||
// Absent means nothing has been measured -- which is not a context of zero, so
|
||||
// it stays null and the row simply does not claim a figure.
|
||||
contextTokens =
|
||||
if (session.isNull("contextTokens")) null
|
||||
else session.optLong("contextTokens").takeIf { it > 0L },
|
||||
// Absent means an older backend that cannot answer, which is exactly what
|
||||
// "unknown" says -- so the default is the honest one rather than "no".
|
||||
inUse = session.optString("inUse", "unknown"),
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -261,8 +260,6 @@ private fun ImportableList(
|
||||
Text(
|
||||
session.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
@@ -277,13 +274,46 @@ private fun ImportableList(
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
detailOf(session, importing),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Top, not centred: the text beside it is now several lines, and
|
||||
// a control centred against it would sit halfway down the row
|
||||
// rather than beside the line it belongs to.
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
// The path first, and the only thing here that is cut:
|
||||
// it is one long value with no natural break, where the
|
||||
// lines below it are short enough to wrap readably.
|
||||
// Cut at the head, because a path is identified by its
|
||||
// tail and these all share a long prefix.
|
||||
session.cwd
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { cwd ->
|
||||
Text(
|
||||
if (cwd.length > PATH_CHARS)
|
||||
"…" + cwd.takeLast(PATH_CHARS)
|
||||
else cwd,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
color =
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
statsOf(session, importing),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Its own line and its own colour, because it differs
|
||||
// in kind from the stats above rather than in degree:
|
||||
// those describe the session, this says whether taking
|
||||
// it is safe at all.
|
||||
warningOf(session)?.let { warning ->
|
||||
Text(
|
||||
warning,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = warningColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Beside the row it acts on, not collected at the bottom of
|
||||
// the screen where its scope would have to be guessed.
|
||||
TextButton(onClick = { onDelete(session) }) {
|
||||
@@ -313,30 +343,38 @@ private fun humanSize(bytes: Long): String? =
|
||||
else -> "$bytes B"
|
||||
}
|
||||
|
||||
/** The second line of a row: what this session is, in the order it is worth knowing. */
|
||||
private fun detailOf(session: Importable, importing: String?): String =
|
||||
/** How much of a path a row shows before cutting its front off. */
|
||||
private const val PATH_CHARS = 40
|
||||
|
||||
/** What this session is: the measurements, in the order they are worth knowing. */
|
||||
private fun statsOf(session: Importable, importing: String?): String =
|
||||
listOfNotNull(
|
||||
if (importing == session.id) "importing…" else null,
|
||||
// First, because it decides whether the rest of the row is worth reading. Words
|
||||
// rather than a colour: "open somewhere else" and "we could not check" are
|
||||
// different in kind, and nothing about a shade says which one this is.
|
||||
when (session.inUse) {
|
||||
"yes" -> "open in a terminal — close it there first"
|
||||
"unknown" -> "can't tell if it's open"
|
||||
else -> null
|
||||
},
|
||||
// Said, because a name and a last message are different claims: one describes the
|
||||
// session, the other is only what happened last in it.
|
||||
if (session.named) "named" else null,
|
||||
// What continuing it costs, which is the question this list is really asked. First
|
||||
// of the measurements for that reason, and absent rather than zero when nothing has
|
||||
// been measured -- a session with no turns yet has no figure, not a figure of none.
|
||||
session.contextTokens?.let { "${it / 1000}k context" },
|
||||
"${session.lines} lines",
|
||||
// Beside the line count rather than instead of it: the two disagree usefully. A
|
||||
// short file of long lines is a session full of screenshots, and that is the one
|
||||
// that is expensive to carry on with.
|
||||
// Kept beside the context figure because the two disagree usefully: most of a large
|
||||
// transcript is history from before a compaction, which the model is no longer
|
||||
// given, so a big file can be cheap to continue and a small one expensive.
|
||||
humanSize(session.bytes),
|
||||
// The tail, not the head: a path is identified by where it ends, and these all share
|
||||
// a long prefix.
|
||||
session.cwd
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { cwd -> if (cwd.length > 28) "…" + cwd.takeLast(28) else cwd },
|
||||
)
|
||||
.joinToString(" · ")
|
||||
|
||||
/**
|
||||
* Why this session might not be safe to take, if it isn't.
|
||||
*
|
||||
* Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind,
|
||||
* and no shade distinguishes them. The colour is what makes it findable; the words are what make it
|
||||
* actionable.
|
||||
*/
|
||||
private fun warningOf(session: Importable): String? =
|
||||
when (session.inUse) {
|
||||
"yes" -> "open in a terminal — close it there first"
|
||||
"unknown" -> "can't tell if it's open"
|
||||
else -> null
|
||||
}
|
||||
@@ -67,6 +67,24 @@ pub struct Importable {
|
||||
/// Epoch seconds, for ordering by "what I was last doing".
|
||||
pub modified: f64,
|
||||
pub lines: usize,
|
||||
/// How many tokens the model was holding at the last turn.
|
||||
///
|
||||
/// The input side of the most recent assistant message's usage --
|
||||
/// prompt plus both cache figures -- which is the closest thing to
|
||||
/// "what continuing this costs", and unlike the size it is a number
|
||||
/// the CLI itself recorded rather than one inferred from the file.
|
||||
///
|
||||
/// Size and this disagree in the direction that matters. Most of a big
|
||||
/// transcript is usually history from before a compaction, which the
|
||||
/// model is no longer given: of the 133 MB session behind the
|
||||
/// 2026-08-29 incident, 99% of the bytes sat before its last
|
||||
/// compaction summary. A 77 MB file whose context is 10k tokens is
|
||||
/// cheap to continue; a smaller one that has never compacted may not
|
||||
/// be.
|
||||
///
|
||||
/// `None` when no assistant turn has recorded usage yet -- which is
|
||||
/// not zero, and is why this is an option rather than a default.
|
||||
pub context_tokens: Option<u64>,
|
||||
/// Size of the file, in bytes.
|
||||
///
|
||||
/// Reported because it is the only thing on a row that predicts what
|
||||
@@ -164,8 +182,9 @@ if [ -d "$HOME/.claude/sessions" ]; then
|
||||
fi
|
||||
for f in "$HOME"/.claude/projects/*/*.jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
printf '%s\t%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" \
|
||||
"$(wc -l < "$f")" "$(stat -c %s "$f" 2>/dev/null || echo 0)" "$f"
|
||||
printf '%s\t%s\t%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" \
|
||||
"$(wc -l < "$f")" "$(stat -c %s "$f" 2>/dev/null || echo 0)" \
|
||||
"$(grep -o '"usage":{[^}]*' "$f" 2>/dev/null | tail -1)" "$f"
|
||||
grep '"type":"custom-title"' "$f" 2>/dev/null | tail -1 | tr '\n' '\037'
|
||||
grep '"type":"user"' "$f" 2>/dev/null | grep -v '"tool_use_id"' | tail -12 | tr '\n' '\037'
|
||||
printf '\n'
|
||||
@@ -204,10 +223,11 @@ done
|
||||
|
||||
/// One line of [`list`]'s output, or nothing if it is not one.
|
||||
fn parse_row(line: &str) -> Option<Importable> {
|
||||
let mut fields = line.splitn(5, '\t');
|
||||
let mut fields = line.splitn(6, '\t');
|
||||
let modified: f64 = fields.next()?.trim().parse().ok()?;
|
||||
let lines: usize = fields.next()?.trim().parse().ok()?;
|
||||
let bytes: u64 = fields.next()?.trim().parse().ok()?;
|
||||
let context_tokens = context_tokens(fields.next()?);
|
||||
let path = fields.next()?.to_string();
|
||||
let id = path.rsplit('/').next()?.strip_suffix(".jsonl")?.to_string();
|
||||
|
||||
@@ -254,10 +274,46 @@ fn parse_row(line: &str) -> Option<Importable> {
|
||||
modified,
|
||||
lines,
|
||||
bytes,
|
||||
context_tokens,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
/// The input tokens named in one `usage` object, added up.
|
||||
///
|
||||
/// Prompt plus cache creation plus cache read: all three are context the
|
||||
/// model was given, and a cached token is cheaper but not free. Output is
|
||||
/// deliberately left out -- it is what the turn produced, not what
|
||||
/// continuing from here has to carry.
|
||||
///
|
||||
/// `None` for an empty blob, meaning no assistant turn has recorded usage.
|
||||
/// Missing individual fields count as zero, which is what an absent
|
||||
/// category means; an unparseable one does the same rather than
|
||||
/// discarding the figures that did read.
|
||||
fn context_tokens(usage: &str) -> Option<u64> {
|
||||
if usage.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The leading quote matters: without it `"input_tokens"` also matches
|
||||
// inside `"cache_read_input_tokens"`, and the same number gets counted
|
||||
// three times.
|
||||
let field = |name: &str| -> u64 {
|
||||
usage
|
||||
.split_once(&format!("\"{name}\":"))
|
||||
.map(|(_, rest)| rest.trim_start())
|
||||
.and_then(|rest| {
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
digits.parse().ok()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
};
|
||||
Some(
|
||||
field("input_tokens")
|
||||
+ field("cache_creation_input_tokens")
|
||||
+ field("cache_read_input_tokens"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The first line of what a person typed, short enough for a list row.
|
||||
///
|
||||
/// None for the CLI's own plumbing. A slash command, the caveat wrapped
|
||||
@@ -586,6 +642,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_tokens_add_the_input_side_only() {
|
||||
// The shape the CLI records, as captured from a real transcript.
|
||||
let usage = r#""usage":{"input_tokens":2,"cache_creation_input_tokens":703,"cache_read_input_tokens":142228,"output_tokens":587,"output_tokens_details":{"thinking_tokens":0"#;
|
||||
// 2 + 703 + 142228. Output is not context to carry forward, so it
|
||||
// is not in the total; if it were, this would read 143520.
|
||||
assert_eq!(context_tokens(usage), Some(142_933));
|
||||
|
||||
// The leading quote is load-bearing: without it "input_tokens"
|
||||
// matches inside both cache field names and the prompt figure gets
|
||||
// counted three times.
|
||||
let only_cache = r#""usage":{"cache_read_input_tokens":100,"output_tokens":9"#;
|
||||
assert_eq!(context_tokens(only_cache), Some(100));
|
||||
|
||||
// No assistant turn yet is not a context of zero.
|
||||
assert_eq!(context_tokens(""), None);
|
||||
assert_eq!(context_tokens(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_with_no_image_writes_nothing() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in new issue
Block a user