diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt new file mode 100644 index 0000000..cdfcb4f --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt @@ -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 + * ``. 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) : MessagePart() +} + +private val MEMORY_NOTE = + Regex("""(.*?)""", 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 { + val parts = mutableListOf() + 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 +} 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 1e28b2d..d73c979 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -578,7 +578,7 @@ fun SessionScreen( items(items.asReversed()) { item -> when (item) { is TranscriptItem.UserMsg -> UserBubble(item.text) - is TranscriptItem.AssistantMsg -> MarkdownText(item.text) + is TranscriptItem.AssistantMsg -> AssistantMessage(item.text) is TranscriptItem.ToolRun -> ToolCard( tool = item,