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");