Keep the last tool call outside its group once it finishes

A call left its group only while it was running, so the moment a command ended
it vanished behind "Called 3 tools" -- and a session that has run its last
command and is composing its answer, or has finished the turn entirely, spends
most of its time in exactly that state. What folds a call back into its run is
therefore not finishing but being overtaken: anything arriving behind it, a
reply included, makes it history.

Standing outside the run is the call's place in the list as it is now rather
than something recorded on the call, so it is asked of the list while grouping
it, where the rest of that decision already lives.

Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest and lintDebug,
and on the emulator against the sandbox: "/tools 3 1" settles as "Called 2
tools" with the third Bash card beneath it, and folds to "Called 3 tools" the
moment the next reply lands.
This commit is contained in:
iris-ai committed 2026-09-15 01:52:51 -04:00
1 parent 036eb375aa
commit 1e52b2910c
4 files changed
+75 -42

No files matched your search

@@ -693,9 +693,9 @@ fun SessionScreen(
}
// A call opened on its own stays open when it is folded into a group -- either because a second
// call in the same run arrived, or because it finished and rejoined the run it was running
// outside of. Until this, watching a Bash call and having the session make another one shut the
// one being read and folded it behind "Called 2 tools".
// call in the same run arrived, or because something landed behind the last call and it
// rejoined the run it was standing outside of. Until this, watching a Bash call and having the
// session make another one shut the one being read and folded it behind "Called 2 tools".
//
// Per call rather than per group, because a group outlives the calls joining it: considered at
// the moment each call first lands inside one, and never again, so the reader who then shuts
@@ -97,11 +97,10 @@ sealed class TranscriptRow {
* A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words,
* and the run this exists for is the burst of five greps nobody wants to scroll past.
*
* A call that has not finished is left alone too, wherever in its run it sits. What the session is
* doing *now* is the one thing worth seeing without opening anything, and grouping it hides the
* running command behind a heading that counts it. The call rejoins its run when it ends, which is
* the moment it stops being what is happening and becomes history -- and it is a row at the live
* end of the transcript, so nothing above the reader moves when it does.
* The last call is left alone too, and so is one still running wherever in its run it sits. What
* the session is doing, or did last, is the one thing worth seeing without opening anything, and a
* heading counting it hides it. What folds a call back into its run is therefore not finishing but
* being overtaken: anything arriving behind it, a reply included, makes it history.
*/
fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> =
DebugStats.timed("grouped tool runs") { groupRuns(items) }
@@ -118,10 +117,10 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
val first = run.firstOrNull() ?: return
// The first row a run produces keeps the run's name, which is the name that survives a page
// of history landing in front of it ([adoptRun]); losing it is the transcript stepping
// under whoever is reading. The pieces a running call cuts off the back of the run have no
// such name, so each takes its own first call's id, which is unique because a call id is.
// The run's name goes in front of it because the two can otherwise be the same string: the
// call a run was named after can itself be the one still running.
// under whoever is reading. The pieces a call standing on its own cuts off the back of the
// run have no such name, so each takes its own first call's id, which is unique because a
// call id is. The run's name goes in front of it because the two can otherwise be the same
// string: the call a run was named after can itself be the one standing on its own.
val key = if (emitted == 0) first.runId else "${first.runId}/${first.id}"
rows +=
if (run.size == 1) TranscriptRow.Single(first, key)
@@ -130,7 +129,7 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
run = mutableListOf()
}
items.forEach { item ->
items.forEachIndexed { index, item ->
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
// Adjacency is the same answer most of the time and a worse one at the edges: a call
// arriving next to an existing run, or a page of history arriving in front of one, both
@@ -143,7 +142,10 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
}
when {
call == null -> rows += TranscriptRow.Single(item)
call.done -> run += call
// Standing outside the run is the call's place in the list as it is now, not something
// recorded on the call: the same finished call is a row of its own while it is the last
// thing that happened and part of its group once a reply lands behind it.
call.done && index != items.lastIndex -> run += call
else -> {
flush()
run += call