Show what was remembered as a note, not as markup

Claude Code marks a sentence taken from its stored memory by wrapping it
in `<cc-memory filenames="...">`. Markdown has nothing to say about that,
so it arrived as literal angle brackets mid-sentence and read as the
model having emitted broken HTML. It is the opposite: a claim about
where something came from, and "I was told this before" and "I worked
this out just now" are different things the reader could not otherwise
tell apart.

Each one becomes a card naming the files it came from, with the prose
either side of it left as prose. Named rather than merely tinted, since
a colour can say "this one is different" but not what kind of different.

A tag still arriving is left alone: streaming means the closing half may
be seconds away, and a half-written marker is not a marker yet.

Verified on the emulator with two notes in one message, one of them
citing two files, and prose before, between and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-29 06:27:00 -04:00
1 parent c5dbd1535d
commit d580461bd5
2 files changed
+93 -1

No files matched your search

@@ -0,0 +1,92 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* An assistant's reply, with anything it says it remembered drawn as a note rather than as markup.
*
* Claude Code marks a sentence that came from its stored memory by wrapping it in
* `<cc-memory filenames="...">`. Markdown has nothing to say about that, so it arrived on screen as
* literal angle brackets in the middle of a sentence -- which reads as the model having emitted
* broken HTML. It is really the opposite: a claim about where something came from, which is worth
* showing, because "I was told this before" and "I worked this out just now" are different things
* and the reader cannot otherwise tell them apart.
*
* A tag that has not finished arriving is left alone. Streaming means the closing tag may be
* seconds away, and a half-written marker is not a marker yet.
*/
@Composable
fun AssistantMessage(text: String, modifier: Modifier = Modifier) {
val parts = remember(text) { splitMemoryNotes(text) }
if (parts.size == 1 && parts[0] is MessagePart.Prose) {
MarkdownText(text, modifier)
return
}
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
parts.forEach { part ->
when (part) {
is MessagePart.Prose -> MarkdownText(part.text)
is MessagePart.Remembered -> MemoryNote(part)
}
}
}
}
@Composable
private fun MemoryNote(note: MessagePart.Remembered) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
// Named, not just tinted: a colour can say "this one is different", but it cannot say
// what kind of different, and "recalled from a file" is a difference in kind.
Text(
if (note.files.size == 1) "remembered from ${note.files[0]}"
else "remembered from ${note.files.joinToString(", ")}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
MarkdownText(note.text, Modifier.padding(top = 4.dp))
}
}
}
/** One piece of a reply: ordinary prose, or a sentence attributed to a memory file. */
sealed class MessagePart {
data class Prose(val text: String) : MessagePart()
data class Remembered(val text: String, val files: List<String>) : MessagePart()
}
private val MEMORY_NOTE =
Regex("""<cc-memory\s+filenames="([^"]*)"\s*>(.*?)</cc-memory>""", RegexOption.DOT_MATCHES_ALL)
/**
* Splits [text] into prose and memory notes, in order.
*
* Always returns at least one part, so a message with no notes in it is one piece of prose and
* costs nothing extra to draw.
*/
fun splitMemoryNotes(text: String): List<MessagePart> {
val parts = mutableListOf<MessagePart>()
var at = 0
for (match in MEMORY_NOTE.findAll(text)) {
val before = text.substring(at, match.range.first)
if (before.isNotBlank()) parts += MessagePart.Prose(before.trim())
val files =
match.groupValues[1].split(",").map { it.trim() }.filter { it.isNotEmpty() }
parts += MessagePart.Remembered(match.groupValues[2].trim(), files)
at = match.range.last + 1
}
val rest = text.substring(at)
if (rest.isNotBlank() || parts.isEmpty()) parts += MessagePart.Prose(rest.trim())
return parts
}
@@ -578,7 +578,7 @@ fun SessionScreen(
items(items.asReversed()) { item -> items(items.asReversed()) { item ->
when (item) { when (item) {
is TranscriptItem.UserMsg -> UserBubble(item.text) is TranscriptItem.UserMsg -> UserBubble(item.text)
is TranscriptItem.AssistantMsg -> MarkdownText(item.text) is TranscriptItem.AssistantMsg -> AssistantMessage(item.text)
is TranscriptItem.ToolRun -> is TranscriptItem.ToolRun ->
ToolCard( ToolCard(
tool = item, tool = item,