From 19e3525131b1533080360ad3bde705b4655eb378 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 29 Aug 2026 05:54:05 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 --- .../src/main/kotlin/com/example/aiapp/Api.kt | 13 +++ .../kotlin/com/example/aiapp/ImportScreen.kt | 94 +++++++++++++------ server/src/session/import.rs | 81 +++++++++++++++- 3 files changed, 157 insertions(+), 31 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 826fb6c..892cca6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -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 = 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"), diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index f334f77..0416e5b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -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 + } diff --git a/server/src/session/import.rs b/server/src/session/import.rs index f1e16a6..b22c13f 100644 --- a/server/src/session/import.rs +++ b/server/src/session/import.rs @@ -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, /// 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 { - 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 { 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 { + 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");