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
|
||||
}
|
||||
Reference in new issue
Block a user