diff --git a/AGENTS.md b/AGENTS.md index 82f07a2..9760f76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -245,6 +245,20 @@ first if a remote spawn ever mangles an argument. on the same transcript. What that costs is that the rows below slide up under the reader's finger, so a row that has just moved ignores taps for half a second (`SETTLE_MS`). +- **An answered question keeps its options and marks the one that was + taken**, in the same purple that says "picked" while it is still open -- + it does not collapse into a line repeating the answer. The options are + what the question *was*, and "Deny" alone does not say that Allow was the + alternative. One rule in two places (`AskedQuestion` and `PermissionAsk`), + since a permission is a question with two bare options rather than a + different kind of thing. An answer typed into **Other** matches no option, + so that one is still written out -- the state the marking cannot say. +- **Anything that is a note *about* the conversation rather than a turn in + it is closed by default**: a tool call, a peer message, and now a memory + note (``). Open-ness is the screen's, never the card's -- a + card that remembered for itself forgets the moment the lazy list stops + composing it, so a note opened and scrolled past would shut behind the + reader. - **All transcript text is selectable, from one `SelectionContainer` around the whole list** (`TranscriptList.kt`). Not per row: a transcript is one body of text to a reader, so a selection has to be able to run from a diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt index 42ae30f..36300d1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -70,27 +71,38 @@ fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List) -> } Text(ask.prompt, style = MaterialTheme.typography.bodyLarge) Spacer(Modifier.height(8.dp)) - if (ask.answers.isNotEmpty()) { - // Joined for reading only: they arrived as a list and stay one everywhere else. - Text( - "Answered: ${ask.answers.joinToString(", ")}", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - return@Column - } - if (ask.multiSelect) { + // An answered question keeps its options and marks the one that was taken, rather than + // replacing them with a line repeating it. The options are what the question *was*, and + // dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says + // very little without the three it was chosen over. Marked in the same purple that says + // "picked" while the question is still open, so it is one appearance learned once. + val answered = ask.answers.isNotEmpty() + if (ask.multiSelect && !answered) { MultipleChoice(ask.options, onAnswer) } else if (ask.options.all { it.description == null && it.preview == null }) { // Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words // do not need a card each. - AnswerOptions(ask.options, onAnswer) + AnswerOptions(ask.options, ask.answers, onAnswer.takeUnless { answered }) } else { ask.options.forEach { option -> - OptionCard(option, selected = false) { onAnswer(listOf(option.label)) } + OptionCard(option, selected = option.label in ask.answers) { + if (!answered) onAnswer(listOf(option.label)) + } } } - OtherAnswer(onAnswer) + // What was answered in the reader's own words, which no option can mark -- see + // [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered + // by picking says it by the mark alone. + val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } } + if (inWords.isNotEmpty()) { + Text( + "Answered: ${inWords.joinToString(", ")}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp), + ) + } + if (!answered) OtherAnswer(onAnswer) } } @@ -221,14 +233,41 @@ private fun OtherAnswer(onAnswer: (List) -> Unit) { * way for a list of choices to be wrong. */ @Composable -fun AnswerOptions(options: List, onAnswer: (List) -> Unit) { +fun AnswerOptions( + options: List, + /** What was chosen, marked rather than restated; empty while the question is open. */ + answers: List = emptyList(), + /** Null once the question is answered -- the buttons stay, and stop being buttons. */ + onAnswer: ((List) -> Unit)?, +) { FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(), ) { options.forEach { option -> - OutlinedButton(onClick = { onAnswer(listOf(option.label)) }) { Text(option.label) } + val taken = option.label in answers + OutlinedButton( + onClick = { onAnswer?.invoke(listOf(option.label)) }, + // Disabled rather than removed, so an answered question still shows what it + // offered. Material dims a disabled button's own border and label, which would + // take the mark with it -- both are stated here instead. + enabled = onAnswer != null, + border = + BorderStroke( + if (taken) 2.dp else 1.dp, + if (taken) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outlineVariant, + ), + colors = + ButtonDefaults.outlinedButtonColors( + disabledContentColor = + if (taken) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ), + ) { + Text(option.label) + } } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt index be47a74..87ae626 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt @@ -1,15 +1,21 @@ package com.example.aiapp +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width 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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp /** @@ -29,6 +35,9 @@ import androidx.compose.ui.unit.dp fun AssistantMessage( text: String, replies: ParsedReplies, + /** Which notes are open, by [MessagePart.Remembered.text] -- see [MemoryNote]. */ + openNotes: Set, + onToggleNote: (String) -> Unit, modifier: Modifier = Modifier, live: Boolean = false, ) { @@ -43,7 +52,8 @@ fun AssistantMessage( parts.forEach { part -> when (part) { is MessagePart.Prose -> BlockedMarkdown(part.text, replies, live = live) - is MessagePart.Remembered -> MemoryNote(part, replies) + is MessagePart.Remembered -> + MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) } } } } @@ -67,19 +77,53 @@ fun messageParts(text: String): List { return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts } +/** + * One sentence the model attributed to a memory file, closed until somebody asks. + * + * Closed by default, like a tool call and a peer message and for the same reason: it is not part of + * what was said to the reader, it is a note about where a claim came from. Left open it breaks the + * reply in half around a card, which reads as the answer having stopped and restarted -- and these + * arrive several to a message. + * + * What stays visible is which file it came from, because that is the whole of what the note claims + * and it is the part a reader scanning for "why does it think that" is looking for. + * + * Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to + * still be open on the way back, and a card that remembered for itself would forget the moment the + * list stopped composing it. The text is a good enough name -- it does not change once the closing + * tag has arrived, so a note stays open across the moment its reply settles. + */ @Composable -fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) { - Card(Modifier.fillMaxWidth()) { +fun MemoryNote( + note: MessagePart.Remembered, + replies: ParsedReplies, + expanded: Boolean, + onToggle: () -> Unit, +) { + Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) { 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, replies, Modifier.padding(top = 4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + 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, + ) + if (!expanded) { + Spacer(Modifier.width(8.dp)) + Text( + note.text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + // The head, not the tail: a sentence is identified by how it opens. + overflow = TextOverflow.Ellipsis, + ) + } + } + if (expanded) MarkdownText(note.text, replies, Modifier.padding(top = 4.dp)) } } } 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 eece960..508cd48 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -246,6 +246,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching // makes the session re-read the whole conversation, which is worth asking about first. var pendingModel by remember { mutableStateOf(null) } + // What was last taken from the command suggestions, so the list closes behind it; see + // [CommandSuggestions] at its call site. + var picked by remember { mutableStateOf(null) } var expandedTools by remember { mutableStateOf(setOf()) } // Which runs of adjacent tool calls are open. Keyed by the first call's // id, so a group survives more calls arriving after it. @@ -257,6 +260,10 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // by default, which is the rule for anything new in this transcript: a screen that opens // everything it can is one nobody can scan. var expandedNotes by remember { mutableStateOf(setOf()) } + // Which memory notes are open, by the note's own text -- see [MemoryNote]. Closed by default + // like everything else new in this transcript, and held here rather than in the card so a + // note opened and scrolled past is still open on the way back. + var openMemories by remember { mutableStateOf(setOf()) } // Uploaded-but-not-yet-sent attachment ids; sent with the next message. var pendingAttachments by remember { mutableStateOf(listOf()) } // What this session is set to now, seeded from the row that opened it and @@ -923,6 +930,11 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () } } + /** Opens or closes one memory note, wherever it is drawn; see [MemoryNote]. */ + fun toggleMemory(text: String) { + openMemories = if (text in openMemories) openMemories - text else openMemories + text + } + fun act(onDone: () -> Unit = {}, action: () -> Unit) { scope.launch { try { @@ -1209,7 +1221,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () ) { unit -> when (unit) { is TranscriptUnit.Block -> MarkdownText(unit.text, replies) - is TranscriptUnit.Memory -> MemoryNote(unit.part, replies) + is TranscriptUnit.Memory -> + MemoryNote( + unit.part, + replies, + unit.part.text in openMemories, + ) { + toggleMemory(unit.part.text) + } is TranscriptUnit.Whole -> { val row = unit.row Box( @@ -1322,6 +1341,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () AssistantMessage( item.text, replies, + openNotes = openMemories, + onToggleNote = ::toggleMemory, live = true, ) is TranscriptItem.ToolRun -> @@ -1505,8 +1526,17 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // Between the transcript and the box: above what is being typed, so the list does not // cover the thing the command is about, and below everything that explains it. CommandSuggestions( - commands = suggestedCommands(input), - onPick = { command -> input = command.typed() }, + // Nothing to suggest about a suggestion that was just taken. `/compact` is a + // whole command *and* a prefix of itself, so picking it left the list standing + // there with the one row already chosen -- the reader has to dismiss a list that + // has nothing left to offer, in front of the box they are about to send from. + // Held by what was picked rather than by a flag, so typing anything else brings + // the list back without needing a second thing to reset. + commands = if (input == picked) emptyList() else suggestedCommands(input), + onPick = { command -> + input = command.typed() + picked = command.typed() + }, ) // Always enabled -- a send while the session is running becomes a @@ -1579,7 +1609,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // see [ModelSwitchWarning]. onPick = { chosen -> if ( - modelLabel(chosen) == modelLabel(model) || items.isEmpty() + modelLabel(chosen) == modelLabel(model) || + !worthWarningAbout(status, contextTokens, items) ) { act { setSessionModel(settings, summary.id, chosen) } } else { @@ -1806,6 +1837,32 @@ private data class QueuedMessage( val refusal: String? = null, ) +/** + * Whether a model switch has anything to warn about -- see [ModelSwitchWarning]. + * + * What the warning is about is a *cache* being dropped, so the question is whether there is one. + * Two answers say there is not, and both used to produce the dialog anyway: + * + * A session whose process has exited has nothing running to hold a cache, so the next turn was + * always going to re-read the conversation -- the switch adds nothing to that bill. And a session + * reporting zero context is holding nothing, which is what `/clear` leaves behind. + * + * Where the figure is *unknown* rather than zero the fallback is what it always was: whether + * anything has been said at all. Unknown is not nothing, and treating it as nothing would drop the + * warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet + * and the conversation may be enormous. + */ +private fun worthWarningAbout( + status: String, + contextTokens: Long?, + items: List, +): Boolean = + when { + status == "exited" -> false + contextTokens != null -> contextTokens > 0 + else -> items.isNotEmpty() + } + /** * Asked before switching model, because switching is not free and the cost is invisible. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index 55b6f57..8a93503 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -420,15 +420,11 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List