From 53fc59a946fcfb74d6597e7169e6b0ad772c1f63 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Mon, 21 Sep 2026 22:43:45 -0400 Subject: [PATCH] Draw a model's thinking as markdown A model reasons in the same headings, lists and fenced code it answers in, so plain text put rows of hashes and asterisks around the working the reader opened the card to read. The card now draws `MarkdownText`, live while the block is still open so a delta costs a parse of its last block rather than of the whole reasoning, and the words answer the card's own tap through `LocalMarkdownTap` the way a memory note's do. A settled block is warmed with the rest of a page; an open one deliberately is not, since warming a prefix per delta is a parse of the block per delta held for ever. Echo's `/think` fixture is markdown now, because a fixture of flat prose exercises none of this. Co-Authored-By: Claude Opus 5 --- .../kotlin/com/example/aiapp/SessionScreen.kt | 1 + .../kotlin/com/example/aiapp/ThinkingCard.kt | 26 ++++++++++++------- .../com/example/aiapp/TranscriptItems.kt | 4 +++ server/src/session/echo.rs | 13 ++++++++-- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 21cab85..f992901 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -2024,6 +2024,7 @@ fun SessionScreen( is TranscriptItem.ThinkingRow -> ThinkingCard( item = item, + replies = replies, expanded = item.seq in expandedThinking, onToggle = { toggleAnchored( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ThinkingCard.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ThinkingCard.kt index 05eb75c..97415d0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ThinkingCard.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ThinkingCard.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -32,6 +33,7 @@ import androidx.compose.ui.unit.dp @Composable fun ThinkingCard( item: TranscriptItem.ThinkingRow, + replies: ParsedReplies, expanded: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier, @@ -48,16 +50,22 @@ fun ThinkingCard( ) } } + // Markdown, like every other thing the model wrote: a model reasons in the same + // lists, headings and fenced code it answers in, and drawn plainly those arrive as + // rows of hashes and asterisks around the working the reader opened the card to read. + // Still arriving means the incremental parse -- see [MarkdownText] -- since an open + // block gains a delta at a time. + // + // The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap]. if (expanded) { - // Plain text rather than markdown: this is a model talking to itself, so its - // half-finished lists and stray backticks are not markup it meant to write, and - // rendering them as such makes the working look like an answer. - Text( - item.text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 6.dp), - ) + CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) { + MarkdownText( + item.text, + replies, + Modifier.padding(top = 6.dp), + live = item.open, + ) + } } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt index e6f6101..da71e92 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt @@ -718,6 +718,10 @@ suspend fun warm(replies: ParsedReplies, rows: List) { // transcript often enough that leaving it out was the whole of why one cost a fifth // of a second to open. is TranscriptItem.PeerNote -> listOf(row.text) + // A model's working is markdown too, and only once it is settled: an open block + // gains a delta at a time and is drawn by the incremental parse, so warming one + // would hold a parse of every prefix of it. + is TranscriptItem.ThinkingRow -> if (row.open) emptyList() else listOf(row.text) else -> emptyList() } } diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index 3f47a36..32b498e 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -941,10 +941,19 @@ impl EchoDriver { if let Some(think) = think { let started = std::time::Instant::now(); for remaining in (1..=think.as_secs()).rev() { + // Markdown, because the card draws it as markdown and a + // fixture of flat prose exercises none of that: a heading, a + // list, a fence and some inline code are what a model's + // working actually looks like. send(Event::Thinking { delta: format!( - "Considering what to echo back, {remaining}s of it left. \ - The reply is the message, which took some working out.\n\n" + "## Considering what to echo back\n\n\ + `{remaining}s` of it left, and the plan is:\n\n\ + - read the message\n\ + - say it back, *exactly*\n\n\ + ```rust\n\ + let reply = message.clone();\n\ + ```\n\n" ), }); tokio::time::sleep(Duration::from_secs(1)).await;