Keep the running tool call outside its group

A run of adjacent calls is drawn as one collapsed card, which hid the one
thing worth seeing without opening anything: the command the session is
running right now. It is a row of its own while it runs and folds back into
the run when it ends.

Grouping stays a display decision, so the pieces a running call cuts a run
into are keyed there. The first piece keeps the run's name -- that name is
what survives a page of history landing in front of it -- and later pieces
take their own first call's id behind it, since the call a run was named
after can itself be the one running.

The echo rig's /tools gap now runs between a call's start and its end rather
than between one call and the next, which is where a real session's time goes
and what makes the running state observable at all.

Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest (new
ToolRowsTest) and lintDebug, cargo fmt/clippy/test, and on the emulator
against the sandbox: "Called 2 tools" with the live Bash card beneath it.
This commit is contained in:
iris-ai committed 2026-09-15 01:13:03 -04:00
1 parent b9b777acaf
commit 036eb375aa
5 files changed
+172 -45

No files matched your search

@@ -302,10 +302,10 @@ fun SessionScreen(
// words were in. This is whatever was true as of the last frame.
val selecting = selection.selectedTexts.isNotEmpty()
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// Which runs of adjacent tool calls are open. Keyed by the first call's id, so a group survives
// Which runs of adjacent tool calls are open, by the group row's own key, so a group survives
// more calls arriving after it.
var expandedGroups by remember { mutableStateOf(setOf<String>()) }
// Runs already drawn as a group, so the transition into one is noticed exactly once.
// Calls already drawn inside a group, so being folded into one is noticed exactly once each.
var everGrouped by remember { mutableStateOf(setOf<String>()) }
// Which messages from other agents are open, by the seq that identifies their row. Closed by
// default, which is the rule for anything new in this transcript.
@@ -692,19 +692,23 @@ fun SessionScreen(
}
}
// A call opened on its own stays open when a second call in the same run turns it into a group.
// 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".
// 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".
//
// Considered once per run, at the moment it first becomes a group, and never again: after that
// the group's own toggle owns it.
// 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
// the group has shut it.
LaunchedEffect(rows) {
val fresh = rows.filterIsInstance<TranscriptRow.Tools>().filter { it.id !in everGrouped }
val fresh =
rows.filterIsInstance<TranscriptRow.Tools>().flatMap { group ->
group.calls.filter { it.id !in everGrouped }.map { group.key to it.id }
}
if (fresh.isEmpty()) return@LaunchedEffect
expandedGroups =
expandedGroups +
fresh.filter { group -> group.calls.any { it.id in expandedTools } }.map { it.id }
everGrouped = everGrouped + fresh.map { it.id }
expandedGroups + fresh.filter { it.second in expandedTools }.map { it.first }
everGrouped = everGrouped + fresh.map { it.second }
}
// A compaction reports nothing about its own progress -- measured against the CLI, which says
@@ -1624,13 +1628,13 @@ fun SessionScreen(
is TranscriptRow.Tools ->
ToolGroup(
group = row,
expanded = row.id in expandedGroups,
expanded = row.key in expandedGroups,
onToggle = {
toggleAnchored(row) {
expandedGroups =
if (row.id in expandedGroups)
expandedGroups - row.id
else expandedGroups + row.id
if (row.key in expandedGroups)
expandedGroups - row.key
else expandedGroups + row.key
}
},
isToolExpanded = { it in expandedTools },
@@ -61,7 +61,9 @@ sealed class TranscriptRow {
*
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
* that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
* that is belongs to the item ([TranscriptItem.key]) everywhere a row is one thing; where
* [groupRuns] cuts a run into several rows it is the one deciding, and it says so by handing
* each piece its key.
*/
abstract val key: Any
@@ -75,23 +77,15 @@ sealed class TranscriptRow {
*/
abstract val startSeq: Long
data class Single(val item: TranscriptItem) : TranscriptRow() {
override val key: Any
get() = item.key
data class Single(val item: TranscriptItem, override val key: Any = item.key) :
TranscriptRow() {
override val startSeq: Long
get() = item.seq
}
/** Two or more calls with nothing between them; drawn as one collapsed card. */
data class Tools(val calls: List<TranscriptItem.ToolRun>) : TranscriptRow() {
/** The run's own name, which every call in it already carries. */
val id: String
get() = calls.first().runId
override val key: Any
get() = id
data class Tools(val calls: List<TranscriptItem.ToolRun>, override val key: String) :
TranscriptRow() {
override val startSeq: Long
get() = calls.first().seq
}
@@ -102,6 +96,12 @@ 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.
*/
fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> =
DebugStats.timed("grouped tool runs") { groupRuns(items) }
@@ -109,13 +109,24 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> =
private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
val rows = mutableListOf<TranscriptRow>()
var run = mutableListOf<TranscriptItem.ToolRun>()
// The run being walked, and how many rows it has produced so far -- which is what decides the
// keys below.
var runId: String? = null
var emitted = 0
fun flush() {
when (run.size) {
0 -> {}
1 -> rows += TranscriptRow.Single(run.first())
else -> rows += TranscriptRow.Tools(run.toList())
}
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.
val key = if (emitted == 0) first.runId else "${first.runId}/${first.id}"
rows +=
if (run.size == 1) TranscriptRow.Single(first, key)
else TranscriptRow.Tools(run.toList(), key)
emitted++
run = mutableListOf()
}
@@ -124,11 +135,20 @@ private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
// 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
// change which call is *first*.
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
run += item
} else {
val call = item as? TranscriptItem.ToolRun
if (call == null || call.runId != runId) {
flush()
if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item)
runId = call?.runId
emitted = 0
}
when {
call == null -> rows += TranscriptRow.Single(item)
call.done -> run += call
else -> {
flush()
run += call
flush()
}
}
}
flush()