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:
1 parent
036eb375aa
commit
1e52b2910c
4 files changed
+75
-42
No files matched your search
@@ -1099,13 +1099,16 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21).
|
||||
- The transcript rendered from the event stream: markdown, inline images,
|
||||
tool cards, question cards.
|
||||
- **A run of adjacent tool calls is one collapsed card, except for the
|
||||
call still running** (2026-09-15). What the session is doing right now
|
||||
is the one thing worth seeing without opening anything, and a heading
|
||||
counting it hides it; the call folds back into its run the moment it
|
||||
ends, which is the moment it stops being what is happening. Grouping is
|
||||
a display decision (`groupToolRuns`) and a cut run's pieces are keyed
|
||||
there — the first piece keeps the run's name, since that name is what
|
||||
survives a page of history landing in front of it.
|
||||
call still running and the last call in the transcript** (2026-09-15).
|
||||
What the session is doing right now, 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 not finishing but being *overtaken*:
|
||||
anything arriving behind it, a reply included, makes it history, and a
|
||||
session that has run its last command and is composing its answer leaves
|
||||
that command standing until the answer starts. Grouping is a display
|
||||
decision (`groupToolRuns`) and a cut run's pieces are keyed there — the
|
||||
first piece keeps the run's name, since that name is what survives a page
|
||||
of history landing in front of it.
|
||||
- **Anything that is a note *about* the conversation rather than a turn in
|
||||
it is closed by default** — a tool call, a peer message, a memory note.
|
||||
Open-ness is the screen's, never the card's: a card that remembered for
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,9 +5,10 @@ import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* How a run of tool calls is cut into rows: the call still running is drawn on its own, and every
|
||||
* piece the cut leaves behind still has a key of its own -- two rows sharing one key take the app
|
||||
* down, and a key that moves takes the reader's place with it.
|
||||
* How a run of tool calls is cut into rows: the call still running and the last call in the
|
||||
* transcript are drawn on their own, and every piece the cut leaves behind still has a key of its
|
||||
* own -- two rows sharing one key take the app down, and a key that moves takes the reader's place
|
||||
* with it.
|
||||
*/
|
||||
class ToolRowsTest {
|
||||
private var seq = 0L
|
||||
@@ -23,10 +24,13 @@ class ToolRowsTest {
|
||||
done = done,
|
||||
)
|
||||
|
||||
/** Something that is not a tool call, to put behind the run so its last call folds in. */
|
||||
private fun reply() = TranscriptItem.AssistantMsg(seq = ++seq, text = "done")
|
||||
|
||||
private fun shape(rows: List<TranscriptRow>) = rows.map { row ->
|
||||
when (row) {
|
||||
is TranscriptRow.Tools -> row.calls.map { it.id }
|
||||
is TranscriptRow.Single -> listOf((row.item as TranscriptItem.ToolRun).id)
|
||||
is TranscriptRow.Single -> listOf((row.item as? TranscriptItem.ToolRun)?.id ?: "reply")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +41,18 @@ class ToolRowsTest {
|
||||
fun the_call_still_running_is_a_row_of_its_own() {
|
||||
val rows =
|
||||
groupToolRuns(
|
||||
listOf(call("a"), call("b", runId = "a"), call("c", runId = "a", done = false))
|
||||
listOf(
|
||||
call("a"),
|
||||
call("b", runId = "a"),
|
||||
call("c", runId = "a", done = false),
|
||||
call("d", runId = "a"),
|
||||
reply(),
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
listOf(listOf("a", "b"), listOf("c"), listOf("d"), listOf("reply")),
|
||||
shape(rows),
|
||||
)
|
||||
assertEquals(listOf(listOf("a", "b"), listOf("c")), shape(rows))
|
||||
assertKeysDistinct(rows)
|
||||
}
|
||||
|
||||
@@ -52,9 +65,13 @@ class ToolRowsTest {
|
||||
call("b", runId = "a", done = false),
|
||||
call("c", runId = "a"),
|
||||
call("d", runId = "a"),
|
||||
reply(),
|
||||
)
|
||||
)
|
||||
assertEquals(listOf(listOf("a"), listOf("b"), listOf("c", "d")), shape(rows))
|
||||
assertEquals(
|
||||
listOf(listOf("a"), listOf("b"), listOf("c", "d"), listOf("reply")),
|
||||
shape(rows),
|
||||
)
|
||||
assertKeysDistinct(rows)
|
||||
}
|
||||
|
||||
@@ -71,23 +88,34 @@ class ToolRowsTest {
|
||||
assertEquals("b", rows.first().key)
|
||||
}
|
||||
|
||||
/** What happens the moment a command finishes: it folds back into the run it was cut out of. */
|
||||
/**
|
||||
* Finishing is not what folds a call back in -- being overtaken is. A session that has run its
|
||||
* last command and is writing its reply leaves that command standing until the reply starts.
|
||||
*/
|
||||
@Test
|
||||
fun a_call_that_finishes_rejoins_its_run_without_moving_the_run() {
|
||||
val running = listOf(call("a"), call("b", runId = "a", done = false))
|
||||
val finished = listOf(running[0], (running[1] as TranscriptItem.ToolRun).copy(done = true))
|
||||
val before = groupToolRuns(running)
|
||||
val after = groupToolRuns(finished)
|
||||
assertEquals(listOf(listOf("a", "b")), shape(after))
|
||||
// The run keeps the key it was drawn under, so the list rebuilds a row rather than losing
|
||||
// its anchor.
|
||||
assertEquals(before.first().key, after.first().key)
|
||||
fun the_last_call_stays_out_when_it_finishes_and_folds_in_when_something_follows() {
|
||||
val a = call("a")
|
||||
val running = call("b", runId = "a", done = false)
|
||||
val finished = running.copy(done = true)
|
||||
val whileRunning = groupToolRuns(listOf(a, running))
|
||||
val afterItEnds = groupToolRuns(listOf(a, finished))
|
||||
val afterTheReply = groupToolRuns(listOf(a, finished, reply()))
|
||||
assertEquals(listOf(listOf("a"), listOf("b")), shape(whileRunning))
|
||||
assertEquals(listOf(listOf("a"), listOf("b")), shape(afterItEnds))
|
||||
assertEquals(listOf(listOf("a", "b"), listOf("reply")), shape(afterTheReply))
|
||||
// The run keeps the key it was drawn under throughout, so the list rebuilds a row rather
|
||||
// than losing its anchor.
|
||||
assertEquals(whileRunning.first().key, afterItEnds.first().key)
|
||||
assertEquals(whileRunning.first().key, afterTheReply.first().key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_run_of_finished_calls_is_still_one_group() {
|
||||
val rows = groupToolRuns(listOf(call("a"), call("b", runId = "a"), call("c", runId = "a")))
|
||||
assertEquals(listOf(listOf("a", "b", "c")), shape(rows))
|
||||
assertTrue(rows.single() is TranscriptRow.Tools, "$rows")
|
||||
fun a_run_of_finished_calls_is_one_group_once_something_follows_it() {
|
||||
val rows =
|
||||
groupToolRuns(
|
||||
listOf(call("a"), call("b", runId = "a"), call("c", runId = "a"), reply())
|
||||
)
|
||||
assertEquals(listOf(listOf("a", "b", "c"), listOf("reply")), shape(rows))
|
||||
assertTrue(rows.first() is TranscriptRow.Tools, "$rows")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user