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 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-21 22:43:45 -04:00
1 parent 3dbf04f5ec
commit 53fc59a946
4 files changed
+31 -9

No files matched your search

@@ -2024,6 +2024,7 @@ fun SessionScreen(
is TranscriptItem.ThinkingRow -> is TranscriptItem.ThinkingRow ->
ThinkingCard( ThinkingCard(
item = item, item = item,
replies = replies,
expanded = item.seq in expandedThinking, expanded = item.seq in expandedThinking,
onToggle = { onToggle = {
toggleAnchored( toggleAnchored(
@@ -13,6 +13,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -32,6 +33,7 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun ThinkingCard( fun ThinkingCard(
item: TranscriptItem.ThinkingRow, item: TranscriptItem.ThinkingRow,
replies: ParsedReplies,
expanded: Boolean, expanded: Boolean,
onToggle: () -> Unit, onToggle: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -48,19 +50,25 @@ 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) { if (expanded) {
// Plain text rather than markdown: this is a model talking to itself, so its CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
// half-finished lists and stray backticks are not markup it meant to write, and MarkdownText(
// rendering them as such makes the working look like an answer.
Text(
item.text, item.text,
style = MaterialTheme.typography.bodySmall, replies,
color = MaterialTheme.colorScheme.onSurfaceVariant, Modifier.padding(top = 6.dp),
modifier = Modifier.padding(top = 6.dp), live = item.open,
) )
} }
} }
} }
}
} }
/** /**
@@ -718,6 +718,10 @@ suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
// transcript often enough that leaving it out was the whole of why one cost a fifth // transcript often enough that leaving it out was the whole of why one cost a fifth
// of a second to open. // of a second to open.
is TranscriptItem.PeerNote -> listOf(row.text) 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() else -> emptyList()
} }
} }
+11 -2
View File
@@ -941,10 +941,19 @@ impl EchoDriver {
if let Some(think) = think { if let Some(think) = think {
let started = std::time::Instant::now(); let started = std::time::Instant::now();
for remaining in (1..=think.as_secs()).rev() { 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 { send(Event::Thinking {
delta: format!( delta: format!(
"Considering what to echo back, {remaining}s of it left. \ "## Considering what to echo back\n\n\
The reply is the message, which took some working out.\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; tokio::time::sleep(Duration::from_secs(1)).await;